From 950ba1e2e1b6f38beffa2b5c85077e92deb52a74 Mon Sep 17 00:00:00 2001 From: Michael Nowak Date: Tue, 11 Mar 2025 09:45:32 +0000 Subject: [PATCH 001/548] Add optional twilio-call-format config option To be able to set custom TwiML send to the Call API. --- docs/config.md | 26 +++++++++++++++ server/config.go | 2 ++ server/server.yml | 2 ++ server/server_twilio.go | 6 +++- server/server_twilio_test.go | 61 ++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 9479301a..eb63b947 100644 --- a/docs/config.md +++ b/docs/config.md @@ -996,6 +996,32 @@ are the easiest), and then configure the following options: After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. +To customize your message send to Twilio's Call API, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). +This is the default TwiML: + +``` xml + + + + You have a message from notify on topic %s. Message: + + %s + + End of message. + + This message was sent by user %s. It will be repeated three times. + To unsubscribe from calls like this, remove your phone number in the notify web app. + + + Goodbye. +` +``` + +The TwiML is internaly used as a format string: +1. The first `%s` will be replaced with the topic. +1. The second `%s` will be replaced with the message. +1. The third `%s` will be replaced with the message`s sender name. + ## Message limits There are a few message limits that you can configure: diff --git a/server/config.go b/server/config.go index 7267ce9d..9db41db0 100644 --- a/server/config.go +++ b/server/config.go @@ -120,6 +120,7 @@ type Config struct { TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string + TwilioCallFormat string MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -212,6 +213,7 @@ func NewConfig() *Config { TwilioPhoneNumber: "", TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests TwilioVerifyService: "", + TwilioCallFormat: "", MessageSizeLimit: DefaultMessageSizeLimit, MessageDelayMin: DefaultMessageDelayMin, MessageDelayMax: DefaultMessageDelayMax, diff --git a/server/server.yml b/server/server.yml index 7329d37e..4664ae8b 100644 --- a/server/server.yml +++ b/server/server.yml @@ -171,11 +171,13 @@ # - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 # - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586 # - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 +# - twilio-call-format (optional) is the custom TwiML send to the Call API. See: https://www.twilio.com/docs/voice/twiml # # twilio-account: # twilio-auth-token: # twilio-phone-number: # twilio-verify-service: +# twilio-call-format: # Interval in which keepalive messages are sent to the client. This is to prevent # intermediaries closing the connection for inactivity. diff --git a/server/server_twilio.go b/server/server_twilio.go index 9a8ef8ad..37015dd6 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -14,7 +14,7 @@ import ( ) const ( - twilioCallFormat = ` + defaultTwilioCallFormat = ` @@ -65,6 +65,10 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { if u != nil { sender = u.Name } + twilioCallFormat := defaultTwilioCallFormat + if len(s.config.TwilioCallFormat) > 0 { + twilioCallFormat = s.config.TwilioCallFormat + } body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) data := url.Values{} data.Set("From", s.config.TwilioPhoneNumber) diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 89a36051..70b06314 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -202,6 +202,67 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { }) } +func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + c.TwilioCallFormat = ` + + + + Du hast eine Nachricht von notify im Thema %s. Nachricht: + + %s + + Ende der Nachricht. + + Diese Nachricht wurde von Benutzer %s gesendet. Sie wird drei Mal wiederholt. + Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app. + + + Auf Wiederhören. +` + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) + + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { c := newTestConfigWithAuthFile(t) c.TwilioCallsBaseURL = "http://dummy.invalid" From 16900d2c109596bf546e47b5fa04a4ca9209ff95 Mon Sep 17 00:00:00 2001 From: Michael Nowak Date: Mon, 16 Jun 2025 15:14:13 +0200 Subject: [PATCH 002/548] Set twilio-call-format config option in serve command --- cmd/serve.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/serve.go b/cmd/serve.go index 62e0a14a..4b988adc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -172,6 +172,7 @@ func execServe(c *cli.Context) error { twilioAuthToken := c.String("twilio-auth-token") twilioPhoneNumber := c.String("twilio-phone-number") twilioVerifyService := c.String("twilio-verify-service") + twilioCallFormat := c.String("twilio-call-format") messageSizeLimitStr := c.String("message-size-limit") messageDelayLimitStr := c.String("message-delay-limit") totalTopicLimit := c.Int("global-topic-limit") @@ -388,6 +389,7 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService + conf.TwilioCallFormat = twilioCallFormat conf.MessageSizeLimit = int(messageSizeLimit) conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit From 0e79c4bd2a4ae314daa340fc37daa1160709b740 Mon Sep 17 00:00:00 2001 From: tanhuaan Date: Wed, 30 Jul 2025 16:32:57 +0800 Subject: [PATCH 003/548] refactor: use slices.Contains to simplify code Signed-off-by: tanhuaan --- util/sprig/defaults.go | 8 ++------ util/util.go | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go index c5c14308..0d3056b9 100644 --- a/util/sprig/defaults.go +++ b/util/sprig/defaults.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "reflect" + "slices" "strings" ) @@ -95,12 +96,7 @@ func coalesce(v ...any) any { // Returns: // - bool: True if all values are non-empty, false otherwise func all(v ...any) bool { - for _, val := range v { - if empty(val) { - return false - } - } - return true + return !slices.ContainsFunc(v, empty) } // anyNonEmpty checks if at least one value in a list is non-empty. diff --git a/util/util.go b/util/util.go index 73b227af..66e9dfd6 100644 --- a/util/util.go +++ b/util/util.go @@ -12,6 +12,7 @@ import ( "net/netip" "os" "regexp" + "slices" "strconv" "strings" "sync" @@ -49,12 +50,7 @@ func FileExists(filename string) bool { // Contains returns true if needle is contained in haystack func Contains[T comparable](haystack []T, needle T) bool { - for _, s := range haystack { - if s == needle { - return true - } - } - return false + return slices.Contains(haystack, needle) } // ContainsIP returns true if any one of the of prefixes contains the ip. From 2aae3577cb5238a303eec3c8dde82a2c744181b3 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Fri, 17 Oct 2025 17:39:55 -0600 Subject: [PATCH 004/548] add sid, mtime, and deleted to message_cache --- server/message_cache.go | 79 +++++++++++++++++++++++++++--------- server/message_cache_test.go | 29 +++++++++++-- server/types.go | 13 +++++- 3 files changed, 98 insertions(+), 23 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index 03cb4969..2523d66b 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -28,7 +28,9 @@ const ( CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, mid TEXT NOT NULL, + sid TEXT NOT NULL, time INT NOT NULL, + mtime INT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, @@ -48,10 +50,13 @@ const ( user TEXT NOT NULL, content_type TEXT NOT NULL, encoding TEXT NOT NULL, - published INT NOT NULL + published INT NOT NULL, + deleted INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); + CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); CREATE INDEX IF NOT EXISTS idx_time ON messages (time); + CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); @@ -65,56 +70,57 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? AND published = 1 - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesSinceIDQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND id > ? AND published = 1 - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND (id > ? OR published = 0) - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesLatestQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding + SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE time <= ? AND published = 0 - ORDER BY time, id + ORDER BY mtime, id ` selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + updateMessageDeletedQuery = `UPDATE messages SET deleted = 1 WHERE mid = ?` selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` @@ -130,7 +136,7 @@ const ( // Schema management queries const ( - currentSchemaVersion = 13 + currentSchemaVersion = 14 createSchemaVersionTableQuery = ` CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, @@ -259,6 +265,15 @@ const ( migrate12To13AlterMessagesTableQuery = ` CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); ` + + //13 -> 14 + migrate13To14AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN sid TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN mtime INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); + CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); + CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); + ` ) var ( @@ -276,6 +291,7 @@ var ( 10: migrateFrom10, 11: migrateFrom11, 12: migrateFrom12, + 13: migrateFrom13, } ) @@ -393,7 +409,9 @@ func (c *messageCache) addMessages(ms []*message) error { } _, err := stmt.Exec( m.ID, + m.SID, m.Time, + m.MTime, m.Expires, m.Topic, m.Message, @@ -414,6 +432,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, + 0, ) if err != nil { return err @@ -692,12 +711,14 @@ func readMessages(rows *sql.Rows) ([]*message, error) { } func readMessage(rows *sql.Rows) (*message, error) { - var timestamp, expires, attachmentSize, attachmentExpires int64 - var priority int - var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string + var timestamp, mtimestamp, expires, attachmentSize, attachmentExpires int64 + var priority, deleted int + var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string err := rows.Scan( &id, + &sid, ×tamp, + &mtimestamp, &expires, &topic, &msg, @@ -716,6 +737,7 @@ func readMessage(rows *sql.Rows) (*message, error) { &user, &contentType, &encoding, + &deleted, ) if err != nil { return nil, err @@ -746,7 +768,9 @@ func readMessage(rows *sql.Rows) (*message, error) { } return &message{ ID: id, + SID: sid, Time: timestamp, + MTime: mtimestamp, Expires: expires, Event: messageEvent, Topic: topic, @@ -762,6 +786,7 @@ func readMessage(rows *sql.Rows) (*message, error) { User: user, ContentType: contentType, Encoding: encoding, + Deleted: deleted, }, nil } @@ -1016,3 +1041,19 @@ func migrateFrom12(db *sql.DB, _ time.Duration) error { } return tx.Commit() } + +func migrateFrom13(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate13To14AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 14); err != nil { + return err + } + return tx.Commit() +} diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 778f28fe..6878d78d 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -22,9 +22,11 @@ func TestMemCache_Messages(t *testing.T) { func testCacheMessages(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = 1 + m1.MTime = 1000 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = 2 + m2.MTime = 2000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message"))) @@ -102,10 +104,13 @@ func testCacheMessagesScheduled(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m2 := newDefaultMessage("mytopic", "message 2") m2.Time = time.Now().Add(time.Hour).Unix() + m2.MTime = time.Now().Add(time.Hour).UnixMilli() m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! + m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! + m3.MTime = time.Now().Add(time.Minute).UnixMilli() // earlier than m2! m4 := newDefaultMessage("mytopic2", "message 4") m4.Time = time.Now().Add(time.Minute).Unix() + m4.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) require.Nil(t, c.AddMessage(m3)) @@ -179,18 +184,25 @@ func TestMemCache_MessagesSinceID(t *testing.T) { func testCacheMessagesSinceID(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m1.Time = 100 + m1.MTime = 100000 m2 := newDefaultMessage("mytopic", "message 2") m2.Time = 200 + m2.MTime = 200000 m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 + m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 + m3.MTime = time.Now().Add(time.Hour).UnixMilli() // Scheduled, in the future, later than m7 and m5 m4 := newDefaultMessage("mytopic", "message 4") m4.Time = 400 + m4.MTime = 400000 m5 := newDefaultMessage("mytopic", "message 5") - m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 + m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 + m5.MTime = time.Now().Add(time.Minute).UnixMilli() // Scheduled, in the future, later than m7 m6 := newDefaultMessage("mytopic", "message 6") m6.Time = 600 + m6.MTime = 600000 m7 := newDefaultMessage("mytopic", "message 7") m7.Time = 700 + m7.MTime = 700000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) @@ -251,14 +263,17 @@ func testCachePrune(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = now - 10 + m1.MTime = (now - 10) * 1000 m1.Expires = now - 5 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = now - 5 + m2.MTime = (now - 5) * 1000 m2.Expires = now + 5 // In the future m3 := newDefaultMessage("another_topic", "and another one") m3.Time = now - 12 + m3.MTime = (now - 12) * 1000 m3.Expires = now - 2 require.Nil(t, c.AddMessage(m1)) @@ -297,6 +312,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" + m.SID = "m1" m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "flower.jpg", @@ -310,6 +326,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires2 := time.Now().Add(2 * time.Hour).Unix() // Future m = newDefaultMessage("mytopic", "sending you a car") m.ID = "m2" + m.SID = "m2" m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "car.jpg", @@ -323,6 +340,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires3 := time.Now().Add(1 * time.Hour).Unix() // Future m = newDefaultMessage("another-topic", "sending you another car") m.ID = "m3" + m.SID = "m3" m.User = "u_BAsbaAa" m.Sender = netip.MustParseAddr("5.6.7.8") m.Attachment = &attachment{ @@ -378,11 +396,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) { func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" + m.SID = "m1" m.Expires = time.Now().Add(time.Hour).Unix() require.Nil(t, c.AddMessage(m)) m = newDefaultMessage("mytopic", "message with attachment") m.ID = "m2" + m.SID = "m2" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "car.jpg", @@ -395,6 +415,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m = newDefaultMessage("mytopic", "message with external attachment") m.ID = "m3" + m.SID = "m3" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "car.jpg", @@ -406,6 +427,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m = newDefaultMessage("mytopic2", "message with expired attachment") m.ID = "m4" + m.SID = "m4" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "expired-car.jpg", @@ -502,6 +524,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) { // Add delayed message delayedMessage := newDefaultMessage("mytopic", "some delayed message") delayedMessage.Time = time.Now().Add(time.Minute).Unix() + delayedMessage.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(delayedMessage)) // 10, not 11! diff --git a/server/types.go b/server/types.go index 65492e46..9e65045f 100644 --- a/server/types.go +++ b/server/types.go @@ -25,7 +25,9 @@ const ( // message represents a message published to a topic type message struct { ID string `json:"id"` // Random message ID + SID string `json:"sid"` // Message sequence ID for updating message contents Time int64 `json:"time"` // Unix time in seconds + MTime int64 `json:"mtime"` // Unix time in milliseconds Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Event string `json:"event"` // One of the above Topic string `json:"topic"` @@ -42,13 +44,16 @@ type message struct { Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting User string `json:"-"` // UserID of the uploader, used to associated attachments + Deleted int `json:"deleted,omitempty"` } func (m *message) Context() log.Context { fields := map[string]any{ "topic": m.Topic, "message_id": m.ID, + "message_sid": m.SID, "message_time": m.Time, + "message_mtime": m.MTime, "message_event": m.Event, "message_body_size": len(m.Message), } @@ -92,6 +97,7 @@ func newAction() *action { // publishMessage is used as input when publishing as JSON type publishMessage struct { Topic string `json:"topic"` + SID string `json:"sid"` Title string `json:"title"` Message string `json:"message"` Priority int `json:"priority"` @@ -117,6 +123,7 @@ func newMessage(event, topic, msg string) *message { return &message{ ID: util.RandomString(messageIDLength), Time: time.Now().Unix(), + MTime: time.Now().UnixMilli(), Event: event, Topic: topic, Message: msg, @@ -155,7 +162,11 @@ type sinceMarker struct { } func newSinceTime(timestamp int64) sinceMarker { - return sinceMarker{time.Unix(timestamp, 0), ""} + return newSinceMTime(timestamp * 1000) +} + +func newSinceMTime(mtimestamp int64) sinceMarker { + return sinceMarker{time.UnixMilli(mtimestamp), ""} } func newSinceID(id string) sinceMarker { From 83b5470bc57a24e25e0fa3daa65ab8573954c7a1 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Fri, 17 Oct 2025 19:01:41 -0600 Subject: [PATCH 005/548] publish messages with a custom sequence ID --- server/errors.go | 1 + server/server.go | 31 +++++++++++++++++++- server/server_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/server/errors.go b/server/errors.go index c6745779..141adbd7 100644 --- a/server/errors.go +++ b/server/errors.go @@ -125,6 +125,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} + errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: SID invalid", "https://ntfy.sh/docs/publish/#TODO", 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} diff --git a/server/server.go b/server/server.go index 05b5b63a..aae4171e 100644 --- a/server/server.go +++ b/server/server.go @@ -79,6 +79,8 @@ var ( wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) + sidRegex = topicRegex + updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" @@ -542,7 +544,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath { return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v) - } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) { + } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) @@ -955,6 +957,24 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) { + if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) { + pathSID, err := s.sidFromPath(r.URL.Path) + if err != nil { + return false, false, "", "", "", false, err + } + m.SID = pathSID + } else { + sid := readParam(r, "x-sequence-id", "sequence-id", "sid") + if sid != "" { + if sidRegex.MatchString(sid) { + m.SID = sid + } else { + return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid + } + } else { + m.SID = m.ID + } + } cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") @@ -1693,6 +1713,15 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { return topics, parts[1], nil } +// sidFromPath returns the SID from a POST path like /mytopic/sidHere +func (s *Server) sidFromPath(path string) (string, *errHTTP) { + parts := strings.Split(path, "/") + if len(parts) != 3 { + return "", errHTTPBadRequestSIDInvalid + } + return parts[2], nil +} + // topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { s.mu.Lock() diff --git a/server/server_test.go b/server/server_test.go index 41633dd5..9270d010 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -703,6 +703,74 @@ func TestServer_PublishInvalidTopic(t *testing.T) { require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) } +func TestServer_PublishWithSIDInPath(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic/sid", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid", msg.SID) +} + +func TestServer_PublishWithSIDInHeader(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic", "message", map[string]string{ + "sid": "sid", + }) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid", msg.SID) +} + +func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{ + "sid": "sid2", + }) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SID) // SID in path has priority over SID in header +} + +func TestServer_PublishWithSIDInQuery(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SID) +} + +func TestServer_PublishWithSIDViaGet(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SID) +} + +func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic/.", "message", nil) + + require.Equal(t, 404, response.Code) +} + +func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "POST", "/mytopic", "message", map[string]string{ + "X-Sequence-ID": "*&?", + }) + + require.Equal(t, 400, response.Code) + require.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_PollWithQueryFilters(t *testing.T) { s := newTestServer(t, newTestConfig(t)) From 8293a24cf9a91b9508c970057e9450fdabdca38d Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Fri, 17 Oct 2025 22:10:11 -0600 Subject: [PATCH 006/548] update notification text using sid in web app --- web/public/static/langs/en.json | 2 ++ web/public/sw.js | 10 +++++- web/src/app/SubscriptionManager.js | 51 ++++++++++++++++++++++++---- web/src/app/db.js | 4 +-- web/src/app/notificationUtils.js | 12 +++++-- web/src/components/Notifications.jsx | 22 ++++++++++++ 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 3ad04ea7..362a3192 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -70,6 +70,8 @@ "notifications_delete": "Delete", "notifications_copied_to_clipboard": "Copied to clipboard", "notifications_tags": "Tags", + "notifications_sid": "Sequence ID", + "notifications_revisions": "Revisions", "notifications_priority_x": "Priority {{priority}}", "notifications_new_indicator": "New notification", "notifications_attachment_image": "Attachment image", diff --git a/web/public/sw.js b/web/public/sw.js index 56d66f16..471cbee2 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -23,9 +23,17 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast"); const addNotification = async ({ subscriptionId, message }) => { const db = await dbAsync(); + const populatedMessage = message; + + if (!("mtime" in populatedMessage)) { + populatedMessage.mtime = message.time * 1000; + } + if (!("sid" in populatedMessage)) { + populatedMessage.sid = message.id; + } await db.notifications.add({ - ...message, + ...populatedMessage, subscriptionId, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation new: 1, diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index de99b642..00d15d89 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -156,18 +156,41 @@ class SubscriptionManager { // It's actually fine, because the reading and filtering is quite fast. The rendering is what's // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach - return this.db.notifications - .orderBy("time") // Sort by time first + const notifications = await this.db.notifications + .orderBy("mtime") // Sort by time first .filter((n) => n.subscriptionId === subscriptionId) .reverse() .toArray(); + + return this.groupNotificationsBySID(notifications); } async getAllNotifications() { - return this.db.notifications - .orderBy("time") // Efficient, see docs + const notifications = await this.db.notifications + .orderBy("mtime") // Efficient, see docs .reverse() .toArray(); + + return this.groupNotificationsBySID(notifications); + } + + // Collapse notification updates based on sids + groupNotificationsBySID(notifications) { + const results = {}; + notifications.forEach((notification) => { + const key = `${notification.subscriptionId}:${notification.sid}`; + if (key in results) { + if ("history" in results[key]) { + results[key].history.push(notification); + } else { + results[key].history = [notification]; + } + } else { + results[key] = notification; + } + }); + + return Object.values(results); } /** Adds notification, or returns false if it already exists */ @@ -177,9 +200,16 @@ class SubscriptionManager { return false; } try { + const populatedNotification = notification; + if (!("mtime" in populatedNotification)) { + populatedNotification.mtime = notification.time * 1000; + } + if (!("sid" in populatedNotification)) { + populatedNotification.sid = notification.id; + } // sw.js duplicates this logic, so if you change it here, change it there too await this.db.notifications.add({ - ...notification, + ...populatedNotification, subscriptionId, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation new: 1, @@ -195,7 +225,16 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { - const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId })); + const notificationsWithSubscriptionId = notifications.map((notification) => { + const populatedNotification = notification; + if (!("mtime" in populatedNotification)) { + populatedNotification.mtime = notification.time * 1000; + } + if (!("sid" in populatedNotification)) { + populatedNotification.sid = notification.id; + } + return { ...populatedNotification, subscriptionId }; + }); const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { diff --git a/web/src/app/db.js b/web/src/app/db.js index b28fb716..f4d36c1b 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,9 +11,9 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(2).stores({ + db.version(3).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance + notifications: "&id,sid,subscriptionId,time,mtime,new,[subscriptionId+new]", // compound key for query performance users: "&baseUrl,username", prefs: "&key", }); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 0bd5136d..2884e2f3 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -53,6 +53,14 @@ export const badge = "/static/images/mask-icon.svg"; export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => { const image = isImage(message.attachment) ? message.attachment.url : undefined; + let tag; + + if (message.sid) { + tag = message.sid; + } else { + tag = subscriptionId; + } + // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API return [ formatTitleWithDefault(message, defaultTitle), @@ -61,8 +69,8 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to badge, icon, image, - timestamp: message.time * 1_000, - tag: subscriptionId, + timestamp: message.mtime, + tag, renotify: true, silent: false, // This is used by the notification onclick event diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index dceb5b91..40789d08 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -233,10 +233,20 @@ const NotificationItem = (props) => { const handleDelete = async () => { console.log(`[Notifications] Deleting notification ${notification.id}`); await subscriptionManager.deleteNotification(notification.id); + notification.history?.forEach(async (revision) => { + console.log(`[Notifications] Deleting revision ${revision.id}`); + await subscriptionManager.deleteNotification(revision.id); + }); }; const handleMarkRead = async () => { console.log(`[Notifications] Marking notification ${notification.id} as read`); await subscriptionManager.markNotificationRead(notification.id); + notification.history + ?.filter((revision) => revision.new === 1) + .forEach(async (revision) => { + console.log(`[Notifications] Marking revision ${revision.id} as read`); + await subscriptionManager.markNotificationRead(revision.id); + }); }; const handleCopy = (s) => { navigator.clipboard.writeText(s); @@ -248,6 +258,8 @@ const NotificationItem = (props) => { const hasUserActions = notification.actions && notification.actions.length > 0; const showActions = hasAttachmentActions || hasClickAction || hasUserActions; + const showSid = notification.id !== notification.sid || notification.history; + return ( @@ -304,6 +316,16 @@ const NotificationItem = (props) => { {t("notifications_tags")}: {tags} )} + {showSid && ( + + {t("notifications_sid")}: {notification.sid} + + )} + {notification.history && ( + + {t("notifications_revisions")}: {notification.history.length + 1} + + )} {showActions && ( From 4fa265ed28100682babfa46aef2811e34eb74753 Mon Sep 17 00:00:00 2001 From: liilliil Date: Sat, 22 Nov 2025 11:05:12 +0100 Subject: [PATCH 007/548] Translated using Weblate (Esperanto) Currently translated at 0.7% (3 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/eo/ --- web/public/static/langs/eo.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/eo.json b/web/public/static/langs/eo.json index 0967ef42..237262c4 100644 --- a/web/public/static/langs/eo.json +++ b/web/public/static/langs/eo.json @@ -1 +1,5 @@ -{} +{ + "common_cancel": "Nuligi", + "common_save": "Konservi", + "common_add": "Aldoni" +} From b7ae47d61ccb961bb414e337438b38d42500dd29 Mon Sep 17 00:00:00 2001 From: liilliil Date: Sat, 22 Nov 2025 11:05:36 +0100 Subject: [PATCH 008/548] Added translation using Weblate (Slavonic (Old Church)) --- web/public/static/langs/cu.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/cu.json diff --git a/web/public/static/langs/cu.json b/web/public/static/langs/cu.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/cu.json @@ -0,0 +1 @@ +{} From 76bf4a3de752f3d1555ea90d7e71f672f094eaa4 Mon Sep 17 00:00:00 2001 From: liilliil Date: Sat, 22 Nov 2025 11:35:11 +0100 Subject: [PATCH 009/548] Translated using Weblate (Slavonic (Old Church)) Currently translated at 1.4% (6 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cu/ --- web/public/static/langs/cu.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/cu.json b/web/public/static/langs/cu.json index 0967ef42..73f5da0f 100644 --- a/web/public/static/langs/cu.json +++ b/web/public/static/langs/cu.json @@ -1 +1,8 @@ -{} +{ + "common_cancel": "Отмѣнити", + "common_save": "Сохрани", + "common_add": "Приложити", + "common_back": "Назадъ", + "login_form_button_submit": "Въниди", + "signup_form_password": "Таино слово" +} From 025c2963a0b9acf1e1ba31ba25907b6e40ce2748 Mon Sep 17 00:00:00 2001 From: Ali Benkassou <43420058+khazit@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:45:40 +0100 Subject: [PATCH 010/548] Add Simple Observability to integrations list --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index 4613cb58..fede1703 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -42,6 +42,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong. - [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader - [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform +- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform ## Integration via HTTP/SMTP/etc. From 693d2d630f1439b14595b04960dd3a9b6212e62c Mon Sep 17 00:00:00 2001 From: Antonio Enrico Russo Date: Fri, 28 Nov 2025 09:24:44 -0700 Subject: [PATCH 011/548] do not build fbsend with nofirebase The nofirebase build tag should remove all build dependencies on firebase. The fbsend test tool depends on firebase (and is also only useful in builds that use firebase). Hence, disable it. Signed-off-by: Antonio Enrico Russo --- tools/fbsend/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/fbsend/main.go b/tools/fbsend/main.go index 832aeb79..e5d56777 100644 --- a/tools/fbsend/main.go +++ b/tools/fbsend/main.go @@ -1,3 +1,5 @@ +//go:build !nofirebase + package main import ( From b59f451c6a04d8fbe656d8c1587f78f97eb845d7 Mon Sep 17 00:00:00 2001 From: Elie CHARRA Date: Wed, 3 Dec 2025 19:41:42 +0100 Subject: [PATCH 012/548] docs: fix typo on `listen-metrics-http` The correct option is `metrics-listen-http` --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 74325dad..98bcab1f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1531,7 +1531,7 @@ See [Installation for Docker](install.md#docker) for an example of how this coul If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)). -To configure the metrics endpoint, either set `enable-metrics` and/or set the `listen-metrics-http` option to a dedicated +To configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are doing, and/or secure access to the endpoint in your reverse proxy. From 43fe1b9ad85f4a02139d3c1bf58d23f03f01895f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20D=C3=BAi=20Bolinder?= <3706841+mikaeldui@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:57:29 +0200 Subject: [PATCH 013/548] Add Ferron reverse proxy example --- docs/config.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/config.md b/docs/config.md index 74325dad..058e4d50 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1029,6 +1029,36 @@ or the root domain: redir @httpget https://{host}{uri} } ``` + +=== "ferron" + ``` kdl + // /etc/ferron.kdl + // Note that this config is most certainly incomplete. Please help out and let me know what's missing + // via Discord/Matrix or in a GitHub issue. + // Note: Ferron automatically handles both HTTP and WebSockets with proxy + + ntfy.sh { + auto_tls + auto_tls_letsencrypt_production + protocols "h1" "h2" "h3" + + proxy "http://127.0.0.1:2586" + + // Redirect HTTP to HTTPS, but only for GET topic addresses, since we want + // it to work with curl without the annoying https:// prefix + + no_redirect_to_https #true + + condition "is_get_topic" { + is_equal "{method}" "GET" + is_regex "{path}" "^/([-_a-z0-9]{0,64}$|docs/|static/)" + } + + if "is_get_topic" { + no_redirect_to_https #false + } + } + ``` ## Firebase (FCM) !!! info From e0a9e3aa56461b00f79df1ac1ec8d78dd2954659 Mon Sep 17 00:00:00 2001 From: Albert Cervera i Areny Date: Tue, 16 Dec 2025 18:08:58 +0100 Subject: [PATCH 014/548] Translated using Weblate (Catalan) Currently translated at 3.4% (14 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/ --- web/public/static/langs/ca.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/ca.json b/web/public/static/langs/ca.json index 0d8b4bea..4490f1d5 100644 --- a/web/public/static/langs/ca.json +++ b/web/public/static/langs/ca.json @@ -3,5 +3,14 @@ "action_bar_profile_title": "Perfil", "action_bar_settings": "Configuració", "action_bar_account": "Compte", - "common_add": "Afegir" + "common_add": "Afegir", + "common_cancel": "Cancel·la", + "common_save": "Desa", + "common_back": "Enrere", + "common_copy_to_clipboard": "Copia al portaretalls", + "signup_title": "Crea un compte ntfy", + "signup_form_username": "Nom d'usuari", + "signup_form_password": "Contrasenya", + "signup_form_confirm_password": "Confirma la contrasenya", + "signup_form_button_submit": "Dona't d'alta" } From d9d02dbbc14b21ef03d57427c23765d60a5cfeab Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:56:59 -0500 Subject: [PATCH 015/548] Fix formatting in Python example in publish.md --- docs/publish.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index ce3500e8..9c409523 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -705,8 +705,8 @@ As of today, **Markdown is only supported in the web app.** Here's an example of === "Python" ``` python requests.post("https://ntfy.sh/mytopic", - data="Look ma, **bold text**, *italics*, ..." - headers={ "Markdown": "yes" })) + data="Look ma, **bold text**, *italics*, ...", + headers={ "Markdown": "yes" }) ``` === "PHP" From 430135606b93803af22a89e242cc9e80aae1df1b Mon Sep 17 00:00:00 2001 From: faytecCD Date: Thu, 18 Dec 2025 16:03:24 +0800 Subject: [PATCH 016/548] Fix filter api example --- docs/subscribe/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md index d98134e5..a52e17f6 100644 --- a/docs/subscribe/api.md +++ b/docs/subscribe/api.md @@ -295,7 +295,7 @@ Available filters (all case-insensitive): | `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string | | `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string | | `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) | -| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) | +| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic/json?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) | ### Subscribe to multiple topics It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics From 7f842eaeb11eaa4fe3aa1c4a76c61ff9e64283de Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Dec 2025 20:50:11 -0500 Subject: [PATCH 017/548] Release notes --- docs/releases.md | 56 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 29ec8644..6c303755 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,7 +2,31 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). -### ntfy server v2.15.0 +## Current stable releases + +| Component | Version | Release date | +|------------------|---------|--------------| +| ntfy server | v2.15.0 | Nov 16, 2025 | +| ntfy Android app | v1.18.0 | Dec 4, 2025 | +| ntfy iOS app | v1.3 | Nov 26, 2023 | + +Please check out the release notes for [upcoming releases](#not-released-yet) below. + +## ntfy Android app v1.18.0 +Released December 4, 2025 + +**Features:** + +* Added GIF support for preview images ([ntfy-android#76](https://github.com/binwiederhier/ntfy-android/pull/76)/[#532](https://github.com/binwiederhier/ntfy/issues/532), thanks to [@MichaelArkh](https://github.com/MichaelArkh) and [@dimatx](https://github.com/dimatx) for reporting) +* Added WebP support for preview images ([ntfy-android#81](https://github.com/binwiederhier/ntfy-android/pull/81)/[ntfy-android#80](https://github.com/binwiederhier/ntfy-android/issues/80), thanks to [@jokakilla](https://github.com/jokakilla)) +* Added UnifiedPush distributor selection support ([#137](https://github.com/binwiederhier/ntfy-android/pull/137), thanks to [@p1gp1g](https://github.com/p1gp1g)) + +**Bug fixes + maintenance:** + +* Remove REQUEST_INSTALL_PACKAGES permission ([#684](https://github.com/binwiederhier/ntfy/issues/684)) +* Request to ignore battery optimizations before receiving subscription ([ntfy-android#97](https://github.com/binwiederhier/ntfy-android/pull/97), thanks to [@p1gp1g](https://github.com/p1gp1g)) + +## ntfy server v2.15.0 Released Nov 16, 2025 This release adds a `require-login` flag to topics, which forces users to log in before they can @@ -30,11 +54,11 @@ The policies do not allow directly or indirectly linking to paid plans or donati **Changes:** -- Remove the "Donate" button from menu (all variants) -- Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants) -- Remove links to ntfy docs and issue tracker (Play variant only) -- Remove how-to links to ntfy.sh in a few places (Play variant only) -- Remove "Copy topic address" from subscription menu (Play variant only) +* Remove the "Donate" button from menu (all variants) +* Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants) +* Remove links to ntfy docs and issue tracker (Play variant only) +* Remove how-to links to ntfy.sh in a few places (Play variant only) +* Remove "Copy topic address" from subscription menu (Play variant only) ## ntfy Android app v1.17.8 Released September 23, 2025 @@ -1518,4 +1542,22 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -_Nothing to see, move along ..._ \ No newline at end of file +### ntfy Android app v1.19.x + +This release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the +ability to use [dynamic colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors). +**This was a lot of work** and I want to thank [@Bnyro](https://github.com/Bnyro) and [@cyb3rko](https://github.com/cyb3rko) for implementing this. You guys rock! + +**Features:** + +* Moved the user interface to Material 3 and added dynamic color support ([#580](https://github.com/binwiederhier/ntfy/issues/580), + [ntfy-android#56](https://github.com/binwiederhier/ntfy-android/pull/56), [ntfy-android#126](https://github.com/binwiederhier/ntfy-android/pull/126), + [ntfy-android#135](https://github.com/binwiederhier/ntfy-android/pull/135), thanks to [@Bnyro](https://github.com/Bnyro) + and [@cyb3rko](https://github.com/cyb3rko) for the implementation, and to [@RokeJulianLockhart](https://github.com/RokeJulianLockhart) for reporting) + +### ntfy Android app v1.20.x + +**Bug fixes + maintenance:** + +* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140), + thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation) From a380860cab406db492593fc008c53ab98b5c1039 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Dec 2025 20:53:18 -0500 Subject: [PATCH 018/548] Bump --- go.mod | 58 +-- go.sum | 124 +++--- web/package-lock.json | 901 ++++++++++++++++++++++++++++++------------ 3 files changed, 744 insertions(+), 339 deletions(-) diff --git a/go.mod b/go.mod index 811cbcad..e6d91101 100644 --- a/go.mod +++ b/go.mod @@ -6,22 +6,22 @@ toolchain go1.24.5 require ( cloud.google.com/go/firestore v1.20.0 // indirect - cloud.google.com/go/storage v1.57.2 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect + cloud.google.com/go/storage v1.58.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.18.0 - github.com/gabriel-vasile/mimetype v1.4.11 + github.com/gabriel-vasile/mimetype v1.4.12 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-sqlite3 v1.14.32 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.44.0 - golang.org/x/oauth2 v0.33.0 // indirect - golang.org/x/sync v0.18.0 - golang.org/x/term v0.37.0 + golang.org/x/crypto v0.46.0 + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 + golang.org/x/term v0.38.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.256.0 + google.golang.org/api v0.258.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -35,13 +35,13 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/text v0.31.0 + golang.org/x/text v0.32.0 ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.17.0 // indirect + cloud.google.com/go/auth v0.18.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.5.3 // indirect @@ -55,11 +55,11 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -70,35 +70,35 @@ require ( 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.7 // indirect - github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/googleapis/gax-go/v2 v2.16.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 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.67.2 // indirect + github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/sdk v1.38.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect - google.golang.org/grpc v1.76.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9d768eb0..babf7049 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/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.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= -cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= +cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= +cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= 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= @@ -18,16 +18,16 @@ cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qob cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4= -cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk= +cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo= +cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw= firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= 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.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +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.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= @@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e h1:gt7U1Igw0xbJdyaCM5H2CnlAlPSkzrhsebQB6WQWjLA= -github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -58,18 +58,18 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI= github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= -github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -98,8 +98,8 @@ 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.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= 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= @@ -131,8 +131,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -156,24 +156,24 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= -go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -184,8 +184,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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 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= @@ -200,10 +200,10 @@ 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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -211,8 +211,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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= @@ -225,8 +225,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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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= @@ -236,8 +236,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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 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= @@ -249,8 +249,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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -263,22 +263,22 @@ 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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= -google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= +google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc= +google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww= 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-20251111163417-95abcf5c77ba h1:Ze6qXW0j37YCqZdCD2LkzVSxgEWez0cO4NUyd44DiDY= -google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:4FLPzLA8eGAktPOTemJGDgDYRpLYwrNu4u2JtWINhnI= -google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4= -google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM= +google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= 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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/web/package-lock.json b/web/package-lock.json index 2da1d1c0..789b95f7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2298,6 +2298,76 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2728,9 +2798,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "cpu": [ "arm" ], @@ -2742,9 +2812,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "cpu": [ "arm64" ], @@ -2756,9 +2826,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "cpu": [ "arm64" ], @@ -2770,9 +2840,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "cpu": [ "x64" ], @@ -2784,9 +2854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "cpu": [ "arm64" ], @@ -2798,9 +2868,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "cpu": [ "x64" ], @@ -2812,9 +2882,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "cpu": [ "arm" ], @@ -2826,9 +2896,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "cpu": [ "arm" ], @@ -2840,9 +2910,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "cpu": [ "arm64" ], @@ -2854,9 +2924,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "cpu": [ "arm64" ], @@ -2868,9 +2938,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "cpu": [ "loong64" ], @@ -2882,9 +2952,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "cpu": [ "ppc64" ], @@ -2896,9 +2966,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "cpu": [ "riscv64" ], @@ -2910,9 +2980,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "cpu": [ "riscv64" ], @@ -2924,9 +2994,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "cpu": [ "s390x" ], @@ -2938,9 +3008,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "cpu": [ "x64" ], @@ -2952,9 +3022,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "cpu": [ "x64" ], @@ -2966,9 +3036,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "cpu": [ "arm64" ], @@ -2980,9 +3050,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "cpu": [ "arm64" ], @@ -2994,9 +3064,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "cpu": [ "ia32" ], @@ -3008,9 +3078,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "cpu": [ "x64" ], @@ -3022,9 +3092,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "cpu": [ "x64" ], @@ -3136,13 +3206,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", - "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", "peer": true, "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-transition-group": { @@ -3590,9 +3660,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.28", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", - "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3611,9 +3681,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -3631,11 +3701,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -3711,9 +3781,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", "dev": true, "funding": [ { @@ -3855,13 +3925,13 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", - "integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.26.3" + "browserslist": "^4.28.0" }, "funding": { "type": "opencollective", @@ -3937,9 +4007,9 @@ } }, "node_modules/csstype": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz", - "integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4131,6 +4201,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4148,9 +4225,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.254", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", - "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -4180,9 +4257,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -4269,27 +4346,27 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.1", "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", + "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", + "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", + "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" }, "engines": { @@ -5026,6 +5103,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -5420,9 +5514,9 @@ } }, "node_modules/humanize-duration": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.1.tgz", - "integrity": "sha512-hwzSCymnRdFx9YdRkQQ0OYequXiVAV6ZGQA2uzocwB0F4309Ke6pO8dg0P8LHhRQJyVjGteRTAA/zNfEcpXn8A==", + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz", + "integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==", "license": "Unlicense", "funding": { "url": "https://github.com/sponsors/EvanHahn" @@ -5775,6 +5869,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6097,6 +6201,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -6551,6 +6671,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6810,6 +6940,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6894,6 +7031,33 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7072,24 +7236,24 @@ } }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.3" } }, "node_modules/react-i18next": { @@ -7115,9 +7279,9 @@ } }, "node_modules/react-infinite-scroll-component": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", - "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.1.tgz", + "integrity": "sha512-R8YoOyiNDynSWmfVme5LHslsKrP+/xcRUWR2ies8UgUab9dtyw5ECnMCVPPmnmjjF4MWQmfVdRwRWcWaDgeyMA==", "license": "MIT", "dependencies": { "throttle-debounce": "^2.1.0" @@ -7127,9 +7291,9 @@ } }, "node_modules/react-is": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", - "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", "license": "MIT" }, "node_modules/react-refresh": { @@ -7422,9 +7586,9 @@ } }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", "dependencies": { @@ -7438,28 +7602,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" } }, @@ -7737,6 +7901,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/smob": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", @@ -7861,6 +8038,76 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -8002,6 +8249,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8520,9 +8781,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -8666,17 +8927,17 @@ } }, "node_modules/vite-plugin-pwa": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.1.0.tgz", - "integrity": "sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", + "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", - "workbox-build": "^7.3.0", - "workbox-window": "^7.3.0" + "workbox-build": "^7.4.0", + "workbox-window": "^7.4.0" }, "engines": { "node": ">=16.0.0" @@ -8687,8 +8948,8 @@ "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", - "workbox-build": "^7.3.0", - "workbox-window": "^7.3.0" + "workbox-build": "^7.4.0", + "workbox-window": "^7.4.0" }, "peerDependenciesMeta": { "@vite-pwa/assets-generator": { @@ -8847,30 +9108,30 @@ } }, "node_modules/workbox-background-sync": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz", - "integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", + "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" } }, "node_modules/workbox-broadcast-update": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz", - "integrity": "sha512-T9/F5VEdJVhwmrIAE+E/kq5at2OY6+OXXgOWQevnubal6sO92Gjo24v6dCVwQiclAF5NS3hlmsifRrpQzZCdUA==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", + "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" } }, "node_modules/workbox-build": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.3.0.tgz", - "integrity": "sha512-JGL6vZTPlxnlqZRhR/K/msqg3wKP+m0wfEUVosK7gsYzSgeIxvZLi1ViJJzVL7CEeI8r7rGFV973RiEqkP3lWQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", + "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", "dev": true, "license": "MIT", "dependencies": { @@ -8887,33 +9148,33 @@ "common-tags": "^1.8.0", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "^9.0.1", - "glob": "^7.1.6", + "glob": "^11.0.1", "lodash": "^4.17.20", "pretty-bytes": "^5.3.0", - "rollup": "^2.43.1", + "rollup": "^2.79.2", "source-map": "^0.8.0-beta.0", "stringify-object": "^3.3.0", "strip-comments": "^2.0.1", "tempy": "^0.6.0", "upath": "^1.2.0", - "workbox-background-sync": "7.3.0", - "workbox-broadcast-update": "7.3.0", - "workbox-cacheable-response": "7.3.0", - "workbox-core": "7.3.0", - "workbox-expiration": "7.3.0", - "workbox-google-analytics": "7.3.0", - "workbox-navigation-preload": "7.3.0", - "workbox-precaching": "7.3.0", - "workbox-range-requests": "7.3.0", - "workbox-recipes": "7.3.0", - "workbox-routing": "7.3.0", - "workbox-strategies": "7.3.0", - "workbox-streams": "7.3.0", - "workbox-sw": "7.3.0", - "workbox-window": "7.3.0" + "workbox-background-sync": "7.4.0", + "workbox-broadcast-update": "7.4.0", + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-google-analytics": "7.4.0", + "workbox-navigation-preload": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-range-requests": "7.4.0", + "workbox-recipes": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0", + "workbox-streams": "7.4.0", + "workbox-sw": "7.4.0", + "workbox-window": "7.4.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.0.0" } }, "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { @@ -9021,6 +9282,30 @@ "dev": true, "license": "MIT" }, + "node_modules/workbox-build/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -9028,6 +9313,22 @@ "dev": true, "license": "MIT" }, + "node_modules/workbox-build/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/workbox-build/node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -9114,140 +9415,241 @@ } }, "node_modules/workbox-cacheable-response": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.3.0.tgz", - "integrity": "sha512-eAFERIg6J2LuyELhLlmeRcJFa5e16Mj8kL2yCDbhWE+HUun9skRQrGIFVUagqWj4DMaaPSMWfAolM7XZZxNmxA==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", + "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" } }, "node_modules/workbox-core": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz", - "integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", + "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", "dev": true, "license": "MIT" }, "node_modules/workbox-expiration": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.3.0.tgz", - "integrity": "sha512-lpnSSLp2BM+K6bgFCWc5bS1LR5pAwDWbcKt1iL87/eTSJRdLdAwGQznZE+1czLgn/X05YChsrEegTNxjM067vQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", + "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" } }, "node_modules/workbox-google-analytics": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz", - "integrity": "sha512-ii/tSfFdhjLHZ2BrYgFNTrb/yk04pw2hasgbM70jpZfLk0vdJAXgaiMAWsoE+wfJDNWoZmBYY0hMVI0v5wWDbg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", + "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-background-sync": "7.3.0", - "workbox-core": "7.3.0", - "workbox-routing": "7.3.0", - "workbox-strategies": "7.3.0" + "workbox-background-sync": "7.4.0", + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" } }, "node_modules/workbox-navigation-preload": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.3.0.tgz", - "integrity": "sha512-fTJzogmFaTv4bShZ6aA7Bfj4Cewaq5rp30qcxl2iYM45YD79rKIhvzNHiFj1P+u5ZZldroqhASXwwoyusnr2cg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", + "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" } }, "node_modules/workbox-precaching": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.3.0.tgz", - "integrity": "sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", + "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.3.0", - "workbox-routing": "7.3.0", - "workbox-strategies": "7.3.0" + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" } }, "node_modules/workbox-range-requests": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.3.0.tgz", - "integrity": "sha512-EyFmM1KpDzzAouNF3+EWa15yDEenwxoeXu9bgxOEYnFfCxns7eAxA9WSSaVd8kujFFt3eIbShNqa4hLQNFvmVQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", + "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" } }, "node_modules/workbox-recipes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.3.0.tgz", - "integrity": "sha512-BJro/MpuW35I/zjZQBcoxsctgeB+kyb2JAP5EB3EYzePg8wDGoQuUdyYQS+CheTb+GhqJeWmVs3QxLI8EBP1sg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", + "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-cacheable-response": "7.3.0", - "workbox-core": "7.3.0", - "workbox-expiration": "7.3.0", - "workbox-precaching": "7.3.0", - "workbox-routing": "7.3.0", - "workbox-strategies": "7.3.0" + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" } }, "node_modules/workbox-routing": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.3.0.tgz", - "integrity": "sha512-ZUlysUVn5ZUzMOmQN3bqu+gK98vNfgX/gSTZ127izJg/pMMy4LryAthnYtjuqcjkN4HEAx1mdgxNiKJMZQM76A==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", + "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" } }, "node_modules/workbox-strategies": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.3.0.tgz", - "integrity": "sha512-tmZydug+qzDFATwX7QiEL5Hdf7FrkhjaF9db1CbB39sDmEZJg3l9ayDvPxy8Y18C3Y66Nrr9kkN1f/RlkDgllg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", + "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" } }, "node_modules/workbox-streams": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.3.0.tgz", - "integrity": "sha512-SZnXucyg8x2Y61VGtDjKPO5EgPUG5NDn/v86WYHX+9ZqvAsGOytP0Jxp1bl663YUuMoXSAtsGLL+byHzEuMRpw==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", + "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.3.0", - "workbox-routing": "7.3.0" + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0" } }, "node_modules/workbox-sw": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.3.0.tgz", - "integrity": "sha512-aCUyoAZU9IZtH05mn0ACUpyHzPs0lMeJimAYkQkBsOWiqaJLgusfDCR+yllkPkFRxWpZKF8vSvgHYeG7LwhlmA==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", + "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", "dev": true, "license": "MIT" }, "node_modules/workbox-window": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.3.0.tgz", - "integrity": "sha512-qW8PDy16OV1UBaUNGlTVcepzrlzyzNW/ZJvFQQs2j2TzGsg6IKjcpZC1RSquqQnTOafl5pCj5bGfAHlCjOOjdA==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", + "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", "dev": true, "license": "MIT", "dependencies": { "@types/trusted-types": "^2.0.2", - "workbox-core": "7.3.0" + "workbox-core": "7.4.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/wrappy": { @@ -9274,9 +9676,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "optional": true, @@ -9286,6 +9688,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { From 6cacdd47f2c113481d833cfac46892c0abdbcf46 Mon Sep 17 00:00:00 2001 From: luneth <4kn30x69@protonmail.ch> Date: Sat, 20 Dec 2025 21:11:09 +0100 Subject: [PATCH 019/548] Translated using Weblate (French) Currently translated at 100.0% (405 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/ --- web/public/static/langs/fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/public/static/langs/fr.json b/web/public/static/langs/fr.json index fd8fa78c..572ad659 100644 --- a/web/public/static/langs/fr.json +++ b/web/public/static/langs/fr.json @@ -15,7 +15,7 @@ "notifications_attachment_copy_url_title": "Copier l'URL de la pièce jointe dans le presse-papiers", "notifications_attachment_open_title": "Aller à {{url}}", "notifications_attachment_link_expired": "lien de téléchargement expiré", - "nav_button_publish_message": "Publier la notification", + "nav_button_publish_message": "Publier une notification", "notifications_copied_to_clipboard": "Copié dans le presse-papiers", "alert_not_supported_title": "Notifications non prises en charge", "notifications_tags": "Étiquettes", @@ -272,7 +272,7 @@ "account_delete_dialog_button_submit": "Supprimer définitivement le compte", "account_delete_dialog_billing_warning": "Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.", "account_upgrade_dialog_title": "Changer le tarif du compte", - "account_upgrade_dialog_proration_info": "Facturation : Lors d'un changement vers un tiers payant, la différence de prix sera débitée immédiatement. En passant d'un tiers payant a gratuit, votre solde sera utilisé pour payer de futur factures.", + "account_upgrade_dialog_proration_info": "Proratisation : Lors d'un changement vers le haut entre plans payants, la différence de prix sera facturée immédiatement. En cas de diminutions vers un plan plus économique, la balance sera utilisée pour le paiement des factures suivantes.", "account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, veuillez supprimer au moins {{count}} sujets réservés. Vous pouvez supprimer des sujets réservés dans les Paramètres.", "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} sujets réservés", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} messages journaliers", From 4e03c96108b29d457165235afeafa569e42bccb7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 Dec 2025 21:02:02 -0500 Subject: [PATCH 020/548] Bump release notes --- docs/releases.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 6c303755..bb84961e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -7,11 +7,25 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| | ntfy server | v2.15.0 | Nov 16, 2025 | -| ntfy Android app | v1.18.0 | Dec 4, 2025 | +| ntfy Android app | v1.19.4 | Dec 21, 2025 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +### ntfy Android app v1.19.4 +Released December 21, 2025 + +This release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the +ability to use [dynamic colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors). +**This was a lot of work** and I want to thank [@Bnyro](https://github.com/Bnyro) and [@cyb3rko](https://github.com/cyb3rko) for implementing this. You guys rock! + +**Features:** + +* Moved the user interface to Material 3 and added dynamic color support ([#580](https://github.com/binwiederhier/ntfy/issues/580), + [ntfy-android#56](https://github.com/binwiederhier/ntfy-android/pull/56), [ntfy-android#126](https://github.com/binwiederhier/ntfy-android/pull/126), + [ntfy-android#135](https://github.com/binwiederhier/ntfy-android/pull/135), thanks to [@Bnyro](https://github.com/Bnyro) + and [@cyb3rko](https://github.com/cyb3rko) for the implementation, and to [@RokeJulianLockhart](https://github.com/RokeJulianLockhart) for reporting) + ## ntfy Android app v1.18.0 Released December 4, 2025 @@ -1542,19 +1556,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy Android app v1.19.x - -This release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the -ability to use [dynamic colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors). -**This was a lot of work** and I want to thank [@Bnyro](https://github.com/Bnyro) and [@cyb3rko](https://github.com/cyb3rko) for implementing this. You guys rock! - -**Features:** - -* Moved the user interface to Material 3 and added dynamic color support ([#580](https://github.com/binwiederhier/ntfy/issues/580), - [ntfy-android#56](https://github.com/binwiederhier/ntfy-android/pull/56), [ntfy-android#126](https://github.com/binwiederhier/ntfy-android/pull/126), - [ntfy-android#135](https://github.com/binwiederhier/ntfy-android/pull/135), thanks to [@Bnyro](https://github.com/Bnyro) - and [@cyb3rko](https://github.com/cyb3rko) for the implementation, and to [@RokeJulianLockhart](https://github.com/RokeJulianLockhart) for reporting) - ### ntfy Android app v1.20.x **Bug fixes + maintenance:** From 17870cb471e1bdafe535b584d89e4bda434ff1f8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 Dec 2025 21:37:55 -0500 Subject: [PATCH 021/548] Fix use of deprecated secrets file --- server/server_firebase.go | 2 +- tools/fbsend/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/server_firebase.go b/server/server_firebase.go index 1b80172e..13e80b93 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -80,7 +80,7 @@ type firebaseSenderImpl struct { } func newFirebaseSender(credentialsFile string) (firebaseSender, error) { - fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile)) + fb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, credentialsFile)) if err != nil { return nil, err } diff --git a/tools/fbsend/main.go b/tools/fbsend/main.go index 832aeb79..9c5e9f70 100644 --- a/tools/fbsend/main.go +++ b/tools/fbsend/main.go @@ -26,7 +26,7 @@ func main() { } data[kv[0]] = kv[1] } - fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(*conffile)) + fb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, *conffile)) if err != nil { fail(err.Error()) } From 152a6b96d1f029b610e47b7c37e29720bc8da5a2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 Dec 2025 20:23:48 -0500 Subject: [PATCH 022/548] Run go fix --- cmd/publish_unix.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/publish_unix.go b/cmd/publish_unix.go index 3ce22ffc..d2b49a5e 100644 --- a/cmd/publish_unix.go +++ b/cmd/publish_unix.go @@ -1,5 +1,4 @@ //go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd -// +build darwin linux dragonfly freebsd netbsd openbsd package cmd From b27c4cf95d800a19809fb37ba5c4e9b62595f627 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 26 Dec 2025 13:20:20 -0500 Subject: [PATCH 023/548] Release notes --- docs/releases.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index bb84961e..3f9fa7a3 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1562,3 +1562,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140), thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation) + +### ntfy Android app v1.21.x + +**Bug fixes + maintenance:** + +* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco)) From 76fbe19fd14c758a9c30beae08040884ff14c7bc Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 28 Dec 2025 12:48:55 -0500 Subject: [PATCH 024/548] Release notes --- docs/releases.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index 3f9fa7a3..12810619 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1565,6 +1565,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ### ntfy Android app v1.21.x +**Features:** + +* Add message bar to publish messages from the app ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144)) + **Bug fixes + maintenance:** * Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco)) From 954ac89b51b724ad5bbaebffac43f55c468305de Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 28 Dec 2025 20:20:26 -0500 Subject: [PATCH 025/548] Release notes --- docs/releases.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 12810619..0ab9b237 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,6 +12,22 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release Please check out the release notes for [upcoming releases](#not-released-yet) below. +### ntfy Android app v1.20.x +Released December 28, 2025 + +This is the last pure maintenance release for now. It'll bring all dependencies and library version to the latest version, +and fixes some crashes. I had to drop support for about 4,000 devices (only ~200 installations), because the libraries +themselves do not support SDK 21 anymore, which was the previous minimum SDK version (Android 5, 2014). Now the minimum +SDK version is 26 (Android 8, 2017). + +**Bug fixes + maintenance:** + +* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140), + thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation) +* Updated target SDK version to 36 (Android 8, 2017) +* Fixed ForegroundServiceDidNotStartInTimeException ([#1520](https://github.com/binwiederhier/ntfy/issues/1520)) +* Fixed crashes with redrawing the list when temporarily muted topics expire + ### ntfy Android app v1.19.4 Released December 21, 2025 @@ -1556,18 +1572,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy Android app v1.20.x - -**Bug fixes + maintenance:** - -* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140), - thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation) - ### ntfy Android app v1.21.x **Features:** -* Add message bar to publish messages from the app ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144)) +* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144)) +* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting) **Bug fixes + maintenance:** From 6215113c919c5ed7a3fa0e727aa3a3cef1606110 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 29 Dec 2025 16:58:22 -0500 Subject: [PATCH 026/548] Release notes --- docs/releases.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 0ab9b237..fb817a82 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,7 +12,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release Please check out the release notes for [upcoming releases](#not-released-yet) below. -### ntfy Android app v1.20.x +## ntfy Android app v1.20.x Released December 28, 2025 This is the last pure maintenance release for now. It'll bring all dependencies and library version to the latest version, @@ -28,7 +28,7 @@ SDK version is 26 (Android 8, 2017). * Fixed ForegroundServiceDidNotStartInTimeException ([#1520](https://github.com/binwiederhier/ntfy/issues/1520)) * Fixed crashes with redrawing the list when temporarily muted topics expire -### ntfy Android app v1.19.4 +## ntfy Android app v1.19.4 Released December 21, 2025 This release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the @@ -1577,8 +1577,11 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** * Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144)) +* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13)) * Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting) +* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting) **Bug fixes + maintenance:** * Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco)) +* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel)) From d83be77727745dc064268ca7bf27fa0ab42b83aa Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 29 Dec 2025 21:07:31 -0500 Subject: [PATCH 027/548] Release notes --- docs/releases.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index fb817a82..5ef79633 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -4,15 +4,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Current stable releases -| Component | Version | Release date | -|------------------|---------|--------------| -| ntfy server | v2.15.0 | Nov 16, 2025 | -| ntfy Android app | v1.19.4 | Dec 21, 2025 | -| ntfy iOS app | v1.3 | Nov 26, 2023 | +| Component | Version | Release date | +|------------------------------------------|---------|--------------| +| ntfy server | v2.15.0 | Nov 16, 2025 | +| ntfy Android app (_is being rolled out_) | v1.20.0 | Dec 28, 2025 | +| ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. -## ntfy Android app v1.20.x +## ntfy Android app v1.20.0 Released December 28, 2025 This is the last pure maintenance release for now. It'll bring all dependencies and library version to the latest version, @@ -1578,6 +1578,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144)) * Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13)) +* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g)) * Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting) * Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting) From 3dea1b12cf912ff565b968321d11a3897b38d698 Mon Sep 17 00:00:00 2001 From: Shoshin Akamine Date: Tue, 30 Dec 2025 04:27:04 +0100 Subject: [PATCH 028/548] Translated using Weblate (Japanese) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/ --- web/public/static/langs/ja.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json index 3d9643e0..ebd76b54 100644 --- a/web/public/static/langs/ja.json +++ b/web/public/static/langs/ja.json @@ -403,5 +403,7 @@ "prefs_appearance_theme_system": "システム (既定)", "prefs_appearance_theme_dark": "ダークモード", "web_push_unknown_notification_title": "不明な通知を受信しました", - "web_push_unknown_notification_body": "ウェブアプリを開いてntfyをアップデートする必要があります" + "web_push_unknown_notification_body": "ウェブアプリを開いてntfyをアップデートする必要があります", + "account_basics_cannot_edit_or_delete_provisioned_user": "自動作成されたユーザーの編集や削除はできません", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "自動作成されたトークンは編集や削除はできません" } From f52b29931ff8f7fce0a598bd9770beacc0107d68 Mon Sep 17 00:00:00 2001 From: 109247019824 <109247019824@users.noreply.hosted.weblate.org> Date: Mon, 29 Dec 2025 20:12:44 +0100 Subject: [PATCH 029/548] Translated using Weblate (Bulgarian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/ --- web/public/static/langs/bg.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json index 31716755..465e969b 100644 --- a/web/public/static/langs/bg.json +++ b/web/public/static/langs/bg.json @@ -403,5 +403,7 @@ "prefs_appearance_theme_system": "Системна (подразбирана)", "web_push_subscription_expiring_title": "Известията временно ще бъдат спрени", "web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy", - "action_bar_unmute_notifications": "Включване звука на известията" + "action_bar_unmute_notifications": "Включване звука на известията", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Кодът за защита от външна система не може да бъде променян или премахван", + "account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван" } From 293dda3e7930bb66e758d007ec77453a6ae6108e Mon Sep 17 00:00:00 2001 From: Kachelkaiser Date: Mon, 29 Dec 2025 12:07:23 +0100 Subject: [PATCH 030/548] Translated using Weblate (German) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/ --- web/public/static/langs/de.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 0654483a..dc98901e 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -403,5 +403,7 @@ "web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten", "web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom Server empfangen", "web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest", - "prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)" + "prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden", + "account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden" } From f48a9aef2c2ee4587210f427eed27ea5ae0b29e5 Mon Sep 17 00:00:00 2001 From: luneth <4kn30x69@protonmail.ch> Date: Tue, 30 Dec 2025 10:58:50 +0100 Subject: [PATCH 031/548] Translated using Weblate (French) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/ --- web/public/static/langs/fr.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/public/static/langs/fr.json b/web/public/static/langs/fr.json index 572ad659..33112327 100644 --- a/web/public/static/langs/fr.json +++ b/web/public/static/langs/fr.json @@ -64,7 +64,7 @@ "notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web", "notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}", "publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants", - "publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1", + "publish_dialog_tags_placeholder": "Liste d'étiquettes séparée par des virgules, par ex. avertissement,backup-srv1", "publish_dialog_priority_label": "Priorité", "publish_dialog_click_label": "URL du clic", "publish_dialog_click_placeholder": "URL ouverte lors d'un clic sur la notification", @@ -403,5 +403,7 @@ "web_push_subscription_expiring_title": "Les notifications seront suspendues", "web_push_subscription_expiring_body": "Ouvrez ntfy pour continuer à recevoir les notifications", "web_push_unknown_notification_title": "Notification inconnue reçue du serveur", - "web_push_unknown_notification_body": "Il est possible que vous deviez mettre à jour ntfy en ouvrant l'application web" + "web_push_unknown_notification_body": "Il est possible que vous deviez mettre à jour ntfy en ouvrant l'application web", + "account_basics_cannot_edit_or_delete_provisioned_user": "Un utilisateur provisionné ne peut pas être modifié ou supprimé", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossible de modifier ou de supprimer le jeton provisionné" } From 06577c99f252c660c62fab7ecd29e72a69ca4e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 29 Dec 2025 14:52:10 +0100 Subject: [PATCH 032/548] Translated using Weblate (Estonian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/ --- web/public/static/langs/et.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/public/static/langs/et.json b/web/public/static/langs/et.json index 8775900d..19c0c704 100644 --- a/web/public/static/langs/et.json +++ b/web/public/static/langs/et.json @@ -139,7 +139,7 @@ "display_name_dialog_placeholder": "Kuvatav nimi", "publish_dialog_title_no_topic": "Avalda teavitus", "publish_dialog_progress_uploading": "Laadin üles…", - "publish_dialog_message_published": "Teavitus on saadetud", + "publish_dialog_message_published": "Teavitus on avaldatud", "publish_dialog_emoji_picker_show": "Vali emoji", "publish_dialog_priority_low": "Vähetähtis", "publish_dialog_priority_default": "Vaikimisi tähtsus", @@ -403,5 +403,7 @@ "account_upgrade_dialog_proration_info": "Summade jagamine: Kui muudad teenusepaketti paremaks, siis pead hinnavahe maksma kohe. Kui muudad teenusepaketti madalamaks, siis hinnavahe arvelt hüvituvad mõned järgmised maksed.", "account_upgrade_dialog_reservations_warning_one": "Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist palun esmalt kustuta vähemalt üks reserveering. Seda saad teha siin.", "account_upgrade_dialog_reservations_warning_other": "Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist palun esmalt kustuta vähemalt {{count}} reserveeringut. Seda saad teha siin.", - "prefs_users_description": "Oma kaitstud teemade kasutajaid saad lisada ja eemaldada siin. Palun arvesta, et kasutajanimi ja salasõna on salvestatud veebibrauseri kohalikus andmeruumis." + "prefs_users_description": "Oma kaitstud teemade kasutajaid saad lisada ja eemaldada siin. Palun arvesta, et kasutajanimi ja salasõna on salvestatud veebibrauseri kohalikus andmeruumis.", + "account_basics_cannot_edit_or_delete_provisioned_user": "Eelsisestatud kasutajat ei saa muuta ega kustutada", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Eelsisestatud tunnusluba ei saa muuta ega kustutada" } From b348bce06e2457bfe5f0d5f99d8de65d8e71806a Mon Sep 17 00:00:00 2001 From: Eskuero <3skuero@gmail.com> Date: Mon, 29 Dec 2025 19:50:19 +0100 Subject: [PATCH 033/548] Translated using Weblate (Spanish) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/ --- web/public/static/langs/es.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index 1d516e5d..598d271a 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -404,5 +404,7 @@ "prefs_appearance_theme_dark": "Oscuro", "web_push_subscription_expiring_body": "Abrir ntfy para seguir recibiendo notificaciones", "web_push_unknown_notification_title": "Notificación desconocida recibida del servidor", - "web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web" + "web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web", + "account_basics_cannot_edit_or_delete_provisioned_user": "Un usuario provisionado no se puede editar o eliminar", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "No se puede editar o eliminar un token provisionado" } From c0f57a448d760217c07a2e1abe699df76bbd6a64 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 30 Dec 2025 10:06:47 -0500 Subject: [PATCH 034/548] Document ?display= parameter, update changelog --- docs/releases.md | 1 + docs/subscribe/phone.md | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 5ef79633..74f5c9ef 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1581,6 +1581,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g)) * Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting) * Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting) +* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski)) **Bug fixes + maintenance:** diff --git a/docs/subscribe/phone.md b/docs/subscribe/phone.md index 94798f43..3015be88 100644 --- a/docs/subscribe/phone.md +++ b/docs/subscribe/phone.md @@ -129,10 +129,11 @@ or to simply directly link to a topic from a mobile website. **Supported link formats:** -| Link format | Example | Description | -|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ntfy:///` | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) | -| `ntfy:///?secure=false` | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) | +| Link format | Example | Description | +|---------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ntfy:///` | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) | +| `ntfy:///?display=` | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. | +| `ntfy:///?secure=false` | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) | ## Integrations From 90667e7176799f1686dee512e5bc626bcdae2f63 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Tue, 30 Dec 2025 17:19:07 -0500 Subject: [PATCH 035/548] add BRun to list of integrations --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index fede1703..948cd758 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -181,6 +181,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy - [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy. - [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go) +- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go) ## Blog + forum posts From 49b3c724cf87e4a353388c428e5b2f1898593061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=8E=8B=E5=8F=AB=E6=88=91=E6=9D=A5=E5=B7=A1?= =?UTF-8?q?=E5=B1=B1?= Date: Tue, 30 Dec 2025 14:20:07 +0100 Subject: [PATCH 036/548] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/ --- web/public/static/langs/zh_Hans.json | 52 +++++++++++++++------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json index e26e7f14..d421bc35 100644 --- a/web/public/static/langs/zh_Hans.json +++ b/web/public/static/langs/zh_Hans.json @@ -203,17 +203,17 @@ "error_boundary_description": "这显然不应该发生。对此非常抱歉。
如果您有时间,请在GitHub上报告,或通过Discord或Matrix告诉我们。", "prefs_users_table": "用户表", "prefs_users_edit_button": "编辑用户", - "publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup", + "publish_dialog_tags_placeholder": "英文逗号分隔的标签列表,例如 warning, srv1-backup", "publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅文档。", "subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。", "publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)", - "account_usage_basis_ip_description": "此帐户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。", + "account_usage_basis_ip_description": "此账户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。", "account_usage_cannot_create_portal_session": "无法打开计费门户", - "account_delete_title": "删除帐户", - "account_delete_description": "永久删除您的帐户", + "account_delete_title": "删除账户", + "account_delete_description": "永久删除您的账户", "signup_error_username_taken": "用户名 {{username}} 已被占用", - "signup_error_creation_limit_reached": "已达到帐户创建限制", - "login_title": "请登录你的 ntfy 帐户", + "signup_error_creation_limit_reached": "已达到账户创建限制", + "login_title": "请登录你的 ntfy 账户", "action_bar_change_display_name": "更改显示名称", "action_bar_reservation_add": "保留主题", "action_bar_reservation_delete": "移除保留", @@ -223,7 +223,7 @@ "action_bar_profile_logout": "登出", "action_bar_sign_in": "登录", "action_bar_sign_up": "注册", - "nav_button_account": "帐户", + "nav_button_account": "账户", "nav_upgrade_banner_label": "升级到 ntfy Pro", "nav_upgrade_banner_description": "保留主题,更多消息和邮件,以及更大的附件", "alert_not_supported_context_description": "通知仅支持 HTTPS。这是 Notifications API 的限制。", @@ -233,7 +233,7 @@ "reserve_dialog_checkbox_label": "保留主题并配置访问", "subscribe_dialog_subscribe_button_generate_topic_name": "生成名称", "account_basics_username_description": "嘿,那是你 ❤", - "account_basics_password_description": "更改您的帐户密码", + "account_basics_password_description": "更改您的账户密码", "account_basics_password_dialog_title": "更改密码", "account_basics_password_dialog_current_password_label": "当前密码", "account_basics_password_dialog_new_password_label": "新密码", @@ -244,8 +244,8 @@ "account_usage_of_limit": "{{limit}} 的", "account_usage_unlimited": "无限", "account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置", - "account_basics_tier_title": "帐户类型", - "account_basics_tier_description": "您帐户的权限级别", + "account_basics_tier_title": "账户类型", + "account_basics_tier_description": "您账户的权限级别", "account_basics_tier_admin": "管理员", "account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)", "account_basics_tier_admin_suffix_no_tier": "(无等级)", @@ -258,7 +258,7 @@ "account_usage_messages_title": "已发布消息", "account_usage_emails_title": "已发送电子邮件", "account_usage_reservations_title": "保留主题", - "account_usage_reservations_none": "此帐户没有保留主题", + "account_usage_reservations_none": "此账户没有保留主题", "account_usage_attachment_storage_title": "附件存储", "account_usage_attachment_storage_description": "每个文件 {{filesize}},在 {{expiry}} 后删除", "account_upgrade_dialog_button_pay_now": "立即付款并订阅", @@ -276,7 +276,7 @@ "account_tokens_delete_dialog_title": "删除访问令牌", "account_tokens_delete_dialog_description": "在删除访问令牌之前,请确保没有应用程序或脚本正在活跃使用它。 此操作无法撤消。", "account_tokens_delete_dialog_submit_button": "永久删除令牌", - "prefs_users_description_no_sync": "用户和密码不会同步到您的帐户。", + "prefs_users_description_no_sync": "用户和密码不会同步到您的账户。", "prefs_users_table_cannot_delete_or_edit": "无法删除或编辑已登录用户", "prefs_reservations_title": "保留主题", "prefs_reservations_description": "您可以在此处保留主题名称供个人使用。保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。", @@ -305,13 +305,13 @@ "reservation_delete_dialog_action_delete_title": "删除缓存的邮件和附件", "reservation_delete_dialog_action_delete_description": "缓存的邮件和附件将被永久删除。此操作无法撤消。", "reservation_delete_dialog_submit_button": "删除保留", - "account_delete_dialog_description": "这将永久删除您的帐户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。", + "account_delete_dialog_description": "这将永久删除您的账户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。", "account_delete_dialog_label": "密码", "account_delete_dialog_button_cancel": "取消", - "account_delete_dialog_button_submit": "永久删除帐户", - "account_delete_dialog_billing_warning": "删除您的帐户也会立即取消您的计费订阅。您将无法再访问计费仪表板。", - "account_upgrade_dialog_title": "更改帐户等级", - "account_upgrade_dialog_cancel_warning": "这将取消您的订阅,并在 {{date}} 降级您的帐户。在那一天,主题保留以及缓存在服务器上的消息将被删除。", + "account_delete_dialog_button_submit": "永久删除账户", + "account_delete_dialog_billing_warning": "删除您的账户也会立即取消您的计费订阅。您将无法再访问计费仪表板。", + "account_upgrade_dialog_title": "更改账户等级", + "account_upgrade_dialog_cancel_warning": "这将取消您的订阅,并在 {{date}} 降级您的账户。在那一天,主题保留以及缓存在服务器上的消息将被删除。", "account_upgrade_dialog_proration_info": "按比例分配:在付费计划之间升级时,差价将被立刻收取。在降级到较低级别时,余额将被用于支付未来的账单周期。", "account_upgrade_dialog_reservations_warning_one": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,请至少删除 1 项保留。您可以在设置中删除保留。", "account_upgrade_dialog_reservations_warning_other": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,请至少删除 {{count}} 项保留。您可以在设置中删除保留。", @@ -322,30 +322,30 @@ "signup_form_confirm_password": "确认密码", "signup_form_button_submit": "注册", "signup_form_toggle_password_visibility": "切换密码可见性", - "signup_title": "创建一个 ntfy 帐户", + "signup_title": "创建一个 ntfy 账户", "signup_form_username": "用户名", "signup_form_password": "密码", - "signup_already_have_account": "已有帐户?登录!", + "signup_already_have_account": "已有账户?登录!", "signup_disabled": "注册已禁用", "login_form_button_submit": "登录", "login_link_signup": "注册", "login_disabled": "登录已禁用", - "action_bar_account": "帐户", + "action_bar_account": "账户", "action_bar_reservation_edit": "更改保留", "subscribe_dialog_error_topic_already_reserved": "主题已保留", - "account_basics_title": "帐户", + "account_basics_title": "账户", "account_basics_username_title": "用户名", "account_basics_username_admin_tooltip": "你是管理员", "account_basics_password_title": "密码", - "account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的帐户将很快被降级。", - "account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费帐户。", + "account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的账户将很快被降级。", + "account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费账户。", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 总存储空间", "account_upgrade_dialog_tier_selected_label": "已选", "account_upgrade_dialog_tier_current_label": "当前", "account_upgrade_dialog_button_cancel": "取消", "account_upgrade_dialog_button_redirect_signup": "立即注册", "account_tokens_title": "访问令牌", - "account_tokens_description": "通过 ntfy API 发布和订阅时使用访问令牌,因此您不必发送您的帐户凭据。查看文档以了解更多信息。", + "account_tokens_description": "通过 ntfy API 发布和订阅时使用访问令牌,因此您不必发送您的账户凭据。查看文档以了解更多信息。", "account_tokens_table_token_header": "令牌", "account_tokens_table_label_header": "标签", "account_tokens_table_last_access_header": "最后访问", @@ -403,5 +403,7 @@ "web_push_subscription_expiring_title": "通知将被暂停", "web_push_subscription_expiring_body": "打开ntfy以继续接收通知", "web_push_unknown_notification_title": "接收到未知通知", - "web_push_unknown_notification_body": "你可能需要打开网页来更新ntfy" + "web_push_unknown_notification_body": "你可能需要打开网页来更新ntfy", + "account_basics_cannot_edit_or_delete_provisioned_user": "已设置的用户无法被编辑或删除", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "无法编辑或删除已设置的令牌" } From 9301546e6d5d15535f84c45e11fc7b1b1ecb4943 Mon Sep 17 00:00:00 2001 From: ezn24 Date: Wed, 31 Dec 2025 19:03:52 +0100 Subject: [PATCH 037/548] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/ --- web/public/static/langs/zh_Hant.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index 3aecd603..7f2cbdad 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -403,5 +403,7 @@ "web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知", "web_push_subscription_expiring_title": "通知會被暫停", "web_push_unknown_notification_body": "你可能需要開啟網頁來更新ntfy", - "web_push_unknown_notification_title": "接收到不明通知" + "web_push_unknown_notification_title": "接收到不明通知", + "account_basics_cannot_edit_or_delete_provisioned_user": "已佈建的使用者無法編輯或刪除", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "無法編輯或刪除已佈建的權杖" } From 275487b3fdecca3f97970e98a924a37b324aa8b7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 1 Jan 2026 21:49:26 -0500 Subject: [PATCH 038/548] Release notes --- docs/releases.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index 74f5c9ef..e6e27e4b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1587,3 +1587,5 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco)) * Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel)) +* Fix crash in user add dialog (onAddUser) +* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520)) From 4f9f1292f194a567c86e6e04be8556feb92e644b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 2 Jan 2026 07:35:06 -0500 Subject: [PATCH 039/548] Update privacy policy --- docs/privacy.md | 200 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 8 deletions(-) diff --git a/docs/privacy.md b/docs/privacy.md index f89f9aaa..bbf380b4 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,12 +1,196 @@ # Privacy policy -I love free software, and I'm doing this because it's fun. I have no bad intentions, and **I will -never monetize or sell your information, and this service and software will always stay free and open.** +**Last updated:** January 2, 2026 -Neither the server nor the app record any personal information, or share any of the messages and topics with -any outside service. All data is exclusively used to make the service function properly. The only external service -I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see -[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version. +This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information +when you use the ntfy.sh service, web app, and mobile applications (Android and iOS). -For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics -or messages, though typically this is turned off. +## Our commitment to privacy + +We love free software, and we're doing this because it's fun. We have no bad intentions, and **we will +never monetize or sell your information**. The ntfy service and software will always stay free and open source. +If you don't trust us or your messages are sensitive, you can [self-host your own ntfy server](install.md). + +## Information we collect + +### Account information (optional) + +If you create an account on ntfy.sh, we collect: + +- **Username** - A unique identifier you choose +- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password) +- **Email address** - Only if you subscribe to a paid plan (for billing purposes) +- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call) + +You can use ntfy without creating an account. Anonymous usage is fully supported. + +### Messages and notifications + +- **Message content** - Messages you publish are temporarily cached on our servers (default: 12 hours) to support + message polling and to overcome client network disruptions. Messages are deleted after the cache duration expires. +- **Attachments** - File attachments are temporarily stored (default: 3 hours) and then automatically deleted. +- **Topic names** - The topic names you publish to or subscribe to are processed by our servers. + +### Technical information + +- **IP addresses** - Used for rate limiting to prevent abuse. May be temporarily logged for debugging purposes, + though this is typically turned off. +- **Access tokens** - If you create access tokens, we store the token value, an optional label, last access time, + and the IP address of the last access. +- **Web push subscriptions** - If you enable browser notifications, we store your browser's push subscription + endpoint to deliver notifications. + +### Billing information (paid plans only) + +If you subscribe to a paid plan, payment processing is handled by Stripe. We store: + +- Stripe customer ID +- Subscription status and billing period + +We do not store your credit card numbers or payment details directly. These are handled entirely by Stripe. + +## Third-party services + +To provide the ntfy.sh service, we use the following third-party services: + +### Firebase Cloud Messaging (FCM) + +We use Google's Firebase Cloud Messaging to deliver push notifications to Android and iOS devices. When you +receive a notification through the mobile apps (Google Play or App Store versions): + +- Message metadata and content may be transmitted through Google's FCM infrastructure +- Google's [privacy policy](https://policies.google.com/privacy) applies to their handling of this data + +**To avoid FCM entirely:** Download the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/) of +the Android app and use a self-hosted server, or use the instant delivery feature with your own server. + +### Twilio (phone calls) + +If you use the phone call notification feature (`X-Call` header), we use Twilio to: + +- Make voice calls to your verified phone number +- Send SMS or voice calls for phone number verification + +Your phone number is shared with Twilio to deliver these services. Twilio's +[privacy policy](https://www.twilio.com/legal/privacy) applies. + +### Amazon SES (email delivery) + +If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to +deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure. +Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies. + +### Stripe (payments) + +If you subscribe to a paid plan, payments are processed by Stripe. Your payment information is handled directly +by Stripe and is subject to Stripe's [privacy policy](https://stripe.com/privacy). + +Note: We have explicitly disabled Stripe's telemetry features in our integration. + +### Web push providers + +If you enable browser notifications in the ntfy web app, push messages are delivered through your browser +vendor's push service: + +- Google (Chrome) +- Mozilla (Firefox) +- Apple (Safari) +- Microsoft (Edge) + +Your browser's push subscription endpoint is shared with these providers to deliver notifications. + +## Mobile applications + +### Android app + +The Android app is available from two sources: + +- **Google Play Store** - Uses Firebase Cloud Messaging for push notifications. Firebase Analytics is + **explicitly disabled** in our app. +- **F-Droid** - Does not include any Google services or Firebase. Uses a foreground service to maintain + a direct connection to the server. + +The Android app stores the following data locally on your device: + +- Subscribed topics and their settings +- Cached notifications +- User credentials (if you add a server with authentication) +- Application logs (for debugging, stored locally only) + +### iOS app + +The iOS app uses Firebase Cloud Messaging (via Apple Push Notification service) to deliver notifications. +The app stores the following data locally on your device: + +- Subscribed topics +- Cached notifications +- User credentials (if configured) + +## Web application + +The ntfy web app is a static website that stores all data locally in your browser: + +- **IndexedDB** - Stores your subscriptions and cached notifications +- **Local Storage** - Stores your preferences and session information + +No cookies are used for tracking. The web app does not have a backend beyond the ntfy API. + +## Data retention + +| Data type | Retention period | +|-----------|------------------| +| Messages | 12 hours (configurable by server operators) | +| Attachments | 3 hours (configurable by server operators) | +| User accounts | Until you delete your account | +| Access tokens | Until you revoke them or delete your account | +| Phone numbers | Until you remove them or delete your account | +| Web push subscriptions | 60 days of inactivity, then automatically removed | +| Server logs | Varies; debugging logs are typically temporary | + +## Self-hosting + +If you prefer complete control over your data, you can [self-host your own ntfy server](install.md). +When self-hosting: + +- You control all data storage and retention +- You can choose whether to use Firebase, Twilio, email delivery, or any other integrations +- No data is shared with ntfy.sh or any third party (unless you configure those integrations) + +The server and all apps are fully open source: + +- Server: [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy) +- Android app: [github.com/binwiederhier/ntfy-android](https://github.com/binwiederhier/ntfy-android) +- iOS app: [github.com/binwiederhier/ntfy-ios](https://github.com/binwiederhier/ntfy-ios) + +## Data security + +- All connections to ntfy.sh are encrypted using TLS/HTTPS +- Passwords are hashed using bcrypt before storage +- Access tokens are generated using cryptographically secure random values +- The server does not log message content by default + +## Your rights + +You have the right to: + +- **Access** - View your account information and data +- **Delete** - Delete your account and associated data via the web app +- **Export** - Your messages are available via the API while cached + +To delete your account, use the account settings in the web app or contact us. + +## Changes to this policy + +We may update this privacy policy from time to time. Changes will be posted on this page with an updated +"Last updated" date. For significant changes, we may provide additional notice on Discord/Matrix or through the [announcements](https://ntfy.sh/announcements) ntfy topic. + +## Contact + +If you have questions about this privacy policy or our data practices, you can reach us: + +- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) +- **Discord**: [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) +- **Matrix**: [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) +- **Email**: [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh) + +For more information about ntfy, visit [ntfy.sh](https://ntfy.sh). From 4ce9508fd3bf35dc29482ffb585a03e25a5e0d03 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 2 Jan 2026 07:44:39 -0500 Subject: [PATCH 040/548] Privacy policy commit history --- docs/privacy.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/privacy.md b/docs/privacy.md index bbf380b4..e9edb462 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -137,15 +137,15 @@ No cookies are used for tracking. The web app does not have a backend beyond the ## Data retention -| Data type | Retention period | -|-----------|------------------| -| Messages | 12 hours (configurable by server operators) | -| Attachments | 3 hours (configurable by server operators) | -| User accounts | Until you delete your account | -| Access tokens | Until you revoke them or delete your account | -| Phone numbers | Until you remove them or delete your account | +| Data type | Retention period | +|------------------------|---------------------------------------------------| +| Messages | 12 hours (configurable by server operators) | +| Attachments | 3 hours (configurable by server operators) | +| User accounts | Until you delete your account | +| Access tokens | Until you revoke them or delete your account | +| Phone numbers | Until you remove them or delete your account | | Web push subscriptions | 60 days of inactivity, then automatically removed | -| Server logs | Varies; debugging logs are typically temporary | +| Server logs | Varies; debugging logs are typically temporary | ## Self-hosting @@ -182,7 +182,10 @@ To delete your account, use the account settings in the web app or contact us. ## Changes to this policy We may update this privacy policy from time to time. Changes will be posted on this page with an updated -"Last updated" date. For significant changes, we may provide additional notice on Discord/Matrix or through the [announcements](https://ntfy.sh/announcements) ntfy topic. +"Last updated" date. You may also review all changes in the [Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/privacy.md). + +For significant changes, we may provide additional notice on Discord/Matrix or through the +[announcements](https://ntfy.sh/announcements) ntfy topic. ## Contact From eca1ed4d8d2fdb49622fc69edef8832017ea9b70 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 2 Jan 2026 07:59:02 -0500 Subject: [PATCH 041/548] Contact and contributing page --- SECURITY.md | 6 ++-- docs/config.md | 4 +-- docs/contact.md | 68 +++++++++++++++++++++++++++++++++++++++++ docs/contributing.md | 51 +++++++++++++++++++++++++++++++ docs/develop.md | 2 +- docs/faq.md | 10 +++--- docs/privacy.md | 9 ++---- docs/troubleshooting.md | 4 +-- mkdocs.yml | 2 ++ 9 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 docs/contact.md create mode 100644 docs/contributing.md diff --git a/SECURITY.md b/SECURITY.md index 45573756..a96cc823 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,5 +6,7 @@ As of today, I only support the latest version of ntfy. Please make sure you sta ## Reporting a Vulnerability -Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w), -or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`). +Please report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). + +You can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org) +(my username is `binwiederhier`). diff --git a/docs/config.md b/docs/config.md index b04438e7..02418b19 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1013,7 +1013,7 @@ or the root domain: === "caddy" ``` # Note that this config is most certainly incomplete. Please help out and let me know what's missing - # via Discord/Matrix or in a GitHub issue. + # via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue. # Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy ntfy.sh, http://nfty.sh { @@ -1034,7 +1034,7 @@ or the root domain: ``` kdl // /etc/ferron.kdl // Note that this config is most certainly incomplete. Please help out and let me know what's missing - // via Discord/Matrix or in a GitHub issue. + // via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue. // Note: Ferron automatically handles both HTTP and WebSockets with proxy ntfy.sh { diff --git a/docs/contact.md b/docs/contact.md new file mode 100644 index 00000000..f511cee9 --- /dev/null +++ b/docs/contact.md @@ -0,0 +1,68 @@ +# Contact + +This service is run by **Philipp C. Heckel**. There are several ways to get in touch with me and the +ntfy community. Please choose the appropriate channel based on your needs. + +## Community support (free) + +For general questions, feature discussions, and community help, please use one of these public channels: + +| Channel | Link | Description | +|---------|------|-------------| +| **Discord** | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) | Real-time chat with the community (I'm `binwiederhier`) | +| **Matrix** | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) | Bridged from Discord, same community (I'm `binwiederhier`) | +| **Matrix Space** | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org) | Matrix space with all ntfy rooms | +| **Lemmy** | [discuss.ntfy.sh/c/ntfy](https://discuss.ntfy.sh/c/ntfy) | Forum-style discussions | +| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests | + +!!! info "Why public channels?" + Answering questions in public channels benefits the entire community. Other users can learn from the + discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support. + +## Paid support (ntfy Pro subscribers) + +If you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support +via the following channels: + +| Channel | Contact | Description | +|---------|---------|-------------| +| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels | +| **Email** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers | + +Please include your ntfy.sh username when contacting support so we can verify your subscription status. + +## Specific inquiries + +### Privacy inquiries + +For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights +(access, deletion, etc.): + +- **Email**: [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh) + +### General inquiries + +For business inquiries, partnerships, press, or other general questions that don't fit the categories above: + +- **Email**: [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh) + +### Security issues + +If you discover a security vulnerability, please report it responsibly: + +- **Email**: [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh) + +See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md) + +## Announcements + +Stay up to date with ntfy news and releases: + +- **ntfy topic**: Subscribe to [ntfy.sh/announcements](https://ntfy.sh/announcements) for release announcements +- **GitHub Releases**: [github.com/binwiederhier/ntfy/releases](https://github.com/binwiederhier/ntfy/releases) +- **iOS TestFlight**: [Join TestFlight](https://testflight.apple.com/join/P1fFnAm9) for iOS beta testing + +## Contributing + +Want to contribute to ntfy? See the [contributing page](contributing.md) for details on how to help with +code, translations, documentation, or donations. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..d722f079 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,51 @@ +# Contributing + +Thank you for your interest in contributing to ntfy! There are many ways to help, whether you're a developer, +translator, or just an enthusiastic user. + +## Code contributions + +If you'd like to contribute code to ntfy: + +1. Check out the [development guide](develop.md) to set up your environment +2. Look at [open issues](https://github.com/binwiederhier/ntfy/issues) for ideas, or propose your own +3. For larger features or architectural changes, please reach out on [Discord/Matrix](contact.md) first to discuss + before investing significant time +4. Submit a pull request on GitHub + +All contributions are welcome, from small bug fixes to major features. + +## Translations + +Help make ntfy accessible to users around the world! We use Hosted Weblate for translations: + +- **Weblate**: [hosted.weblate.org/projects/ntfy](https://hosted.weblate.org/projects/ntfy/) + +You can start translating immediately without any coding knowledge. + +## Documentation + +Found a typo? Want to improve the docs? Documentation contributions are very welcome: + +- Edit any page directly on GitHub using the edit button +- Submit a pull request with your improvements + +## Bug reports and feature requests + +- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) + +Please search existing issues before creating a new one to avoid duplicates. + +## Donations + +If you'd like to support ntfy financially, donations are gratefully accepted: + +- **GitHub Sponsors**: [github.com/sponsors/binwiederhier](https://github.com/sponsors/binwiederhier) + +Your support helps cover server costs and development time. Even small donations are very much appreciated! + +## Code of Conduct + +Please be respectful and constructive in all interactions. See the +[Code of Conduct](https://github.com/binwiederhier/ntfy/blob/main/CODE_OF_CONDUCT.md) for details. + diff --git a/docs/develop.md b/docs/develop.md index 43ac2d4f..4ddff5ec 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -2,7 +2,7 @@ Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎 I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't -hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**. +hesitate to reach out via one of the channels listed on the [contact page](contact.md). ## ntfy server The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the diff --git a/docs/faq.md b/docs/faq.md index 6ff97cfe..5fa5252c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -94,11 +94,11 @@ I would be humbled if you helped me carry the server and developer account costs appreciated. ## Can I email you? Can I DM you on Discord/Matrix? -While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org), -[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally -**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a -[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities. +For community support, please use the public channels listed on the [contact page](contact.md). I generally +**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing) +plan (see [paid support](contact.md#paid-support-ntfy-pro-subscribers)), or you are inquiring about business +opportunities (see [general inquiries](contact.md#general-inquiries)). I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions -in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users +in public forums benefits others, since I can link to the discussion at a later point in time, or other users may be able to help out. I hope you understand. diff --git a/docs/privacy.md b/docs/privacy.md index e9edb462..5572b3f3 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -189,11 +189,6 @@ For significant changes, we may provide additional notice on Discord/Matrix or t ## Contact -If you have questions about this privacy policy or our data practices, you can reach us: +For privacy-related inquiries, please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh). -- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) -- **Discord**: [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) -- **Matrix**: [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) -- **Email**: [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh) - -For more information about ntfy, visit [ntfy.sh](https://ntfy.sh). +For all other contact options, see the [contact page](contact.md). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d37561c5..3d090306 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,7 +1,7 @@ # Troubleshooting This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list. -If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org) -and ask there. We're happy to help. +If this page does not help, feel free to reach out via one of the channels listed on the [contact page](contact.md). +We're happy to help. ## ntfy server If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing diff --git a/mkdocs.yml b/mkdocs.yml index adaf166b..1e15b5db 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -99,6 +99,8 @@ nav: - "Known issues": known-issues.md - "Deprecation notices": deprecations.md - "Development": develop.md + - "Contributing": contributing.md - "Privacy policy": privacy.md + - "Contact": contact.md From 2cd444ece0807cd2f102458a82bdb7094d0e909d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 2 Jan 2026 08:05:39 -0500 Subject: [PATCH 042/548] Contact page --- docs/contact.md | 51 +++++++++++++++++--------------------------- docs/contributing.md | 8 ------- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/docs/contact.md b/docs/contact.md index f511cee9..3e0c0be7 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -1,58 +1,47 @@ # Contact -This service is run by **Philipp C. Heckel**. There are several ways to get in touch with me and the +This service is run by [Philipp C. Heckel](https://heckel.io). There are several ways to get in touch with me and the ntfy community. Please choose the appropriate channel based on your needs. ## Community support (free) For general questions, feature discussions, and community help, please use one of these public channels: -| Channel | Link | Description | -|---------|------|-------------| -| **Discord** | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) | Real-time chat with the community (I'm `binwiederhier`) | -| **Matrix** | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) | Bridged from Discord, same community (I'm `binwiederhier`) | -| **Matrix Space** | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org) | Matrix space with all ntfy rooms | -| **Lemmy** | [discuss.ntfy.sh/c/ntfy](https://discuss.ntfy.sh/c/ntfy) | Forum-style discussions | -| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests | +| Channel | Link | Description | +|-------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------| +| **Discord** | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) | Real-time chat with the community (I'm `binwiederhier`) | +| **Matrix** | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) | Bridged from Discord, same community (I'm `binwiederhier`) | +| **Matrix Space** | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org) | Matrix space with all ntfy rooms | +| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests | !!! info "Why public channels?" Answering questions in public channels benefits the entire community. Other users can learn from the discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support. -## Paid support (ntfy Pro subscribers) +## Paid support If you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support via the following channels: -| Channel | Contact | Description | -|---------|---------|-------------| -| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels | -| **Email** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers | +| Channel | Contact | Description | +|-----------------------|-----------------------------------------------------|------------------------------------------| +| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers | +| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues | +| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels | Please include your ntfy.sh username when contacting support so we can verify your subscription status. -## Specific inquiries - -### Privacy inquiries - -For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights -(access, deletion, etc.): - -- **Email**: [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh) - -### General inquiries - -For business inquiries, partnerships, press, or other general questions that don't fit the categories above: - -- **Email**: [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh) - ### Security issues -If you discover a security vulnerability, please report it responsibly: +If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md). -- **Email**: [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh) +## Other inquiries -See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md) +For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights +(access, deletion, etc.), please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh). + +For business inquiries, partnerships, press, or other general questions that don't fit the categories above, please +use [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh). ## Announcements diff --git a/docs/contributing.md b/docs/contributing.md index d722f079..620ae257 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -36,14 +36,6 @@ Found a typo? Want to improve the docs? Documentation contributions are very wel Please search existing issues before creating a new one to avoid duplicates. -## Donations - -If you'd like to support ntfy financially, donations are gratefully accepted: - -- **GitHub Sponsors**: [github.com/sponsors/binwiederhier](https://github.com/sponsors/binwiederhier) - -Your support helps cover server costs and development time. Even small donations are very much appreciated! - ## Code of Conduct Please be respectful and constructive in all interactions. See the From 3575abcde364a63822701fd709425d1f95cc2e0f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 2 Jan 2026 08:10:34 -0500 Subject: [PATCH 043/548] Restructure contact page --- docs/contact.md | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/docs/contact.md b/docs/contact.md index 3e0c0be7..2be59cb2 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -3,7 +3,9 @@ This service is run by [Philipp C. Heckel](https://heckel.io). There are several ways to get in touch with me and the ntfy community. Please choose the appropriate channel based on your needs. -## Community support (free) +## Support + +### Community support For general questions, feature discussions, and community help, please use one of these public channels: @@ -18,7 +20,7 @@ For general questions, feature discussions, and community help, please use one o Answering questions in public channels benefits the entire community. Other users can learn from the discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support. -## Paid support +### Paid support If you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support via the following channels: @@ -31,7 +33,7 @@ via the following channels: Please include your ntfy.sh username when contacting support so we can verify your subscription status. -### Security issues +## Security issues If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md). @@ -42,16 +44,3 @@ For questions about our [privacy policy](privacy.md), data handling, or to exerc For business inquiries, partnerships, press, or other general questions that don't fit the categories above, please use [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh). - -## Announcements - -Stay up to date with ntfy news and releases: - -- **ntfy topic**: Subscribe to [ntfy.sh/announcements](https://ntfy.sh/announcements) for release announcements -- **GitHub Releases**: [github.com/binwiederhier/ntfy/releases](https://github.com/binwiederhier/ntfy/releases) -- **iOS TestFlight**: [Join TestFlight](https://testflight.apple.com/join/P1fFnAm9) for iOS beta testing - -## Contributing - -Want to contribute to ntfy? See the [contributing page](contributing.md) for details on how to help with -code, translations, documentation, or donations. From 20ccee5d7d8660eced3dc84b9e3020f4a43ca0b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?jos=C3=A9=20m=2E?= Date: Sat, 3 Jan 2026 15:48:02 +0100 Subject: [PATCH 044/548] Translated using Weblate (Galician) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/gl/ --- web/public/static/langs/gl.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/gl.json b/web/public/static/langs/gl.json index 2fa8c32d..d69da79c 100644 --- a/web/public/static/langs/gl.json +++ b/web/public/static/langs/gl.json @@ -406,5 +406,7 @@ "web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións", "web_push_unknown_notification_title": "Recibida unha notificación descoñecida desde o servidor", "web_push_unknown_notification_body": "Poderías ter que actualizar ntfy abrindo a app web", - "subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada" + "subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada", + "account_basics_cannot_edit_or_delete_provisioned_user": "Unha usuaria predefinida non se pode editar ou eliminar", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Non se pode editar un token de usuaria predefinida" } From e18d64933fdfad2710ad8a930e1f880c2714cb52 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 4 Jan 2026 10:57:11 -0500 Subject: [PATCH 045/548] Release notes --- docs/releases.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index e6e27e4b..afcd849a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1572,7 +1572,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy Android app v1.21.x +### ntfy Android app v1.21.1-rc1 (IN TESTING) **Features:** @@ -1589,3 +1589,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel)) * Fix crash in user add dialog (onAddUser) * Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520)) +* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer)) From 39936a95f87e388c30b030f046706ad84bd77bc8 Mon Sep 17 00:00:00 2001 From: Pixelguin <11445611+Pixelguin@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:11:20 -0800 Subject: [PATCH 046/548] Add troubleshooting steps for "Reconnecting" error on mobile --- docs/troubleshooting.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3d090306..e093647e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -129,3 +129,14 @@ keyboard. ## iOS app Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode. + +## Other + +### "Reconnecting..." / Late notifications on mobile (self-hosted) + +If all of your topics are showing as "Reconnecting" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration: + +* If ntfy is behind a reverse proxy, make sure `behind_proxy` is enabled. +* If ntfy is behind Nginx, make sure WebSockets are enabled. +* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON, so a single topic that receives `403 Forbidden` will prevent the entire request from going through. + * In particular, double-check that your user has permission to read `up*` if you are using UnifiedPush. From f356309f705db10977a1f4f222c4183c5a05a42c Mon Sep 17 00:00:00 2001 From: Pixelguin <11445611+Pixelguin@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:13:53 -0800 Subject: [PATCH 047/548] Clarify up* r/w permissions --- docs/troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e093647e..4b45a939 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -139,4 +139,4 @@ If all of your topics are showing as "Reconnecting" and notifications are taking * If ntfy is behind a reverse proxy, make sure `behind_proxy` is enabled. * If ntfy is behind Nginx, make sure WebSockets are enabled. * Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON, so a single topic that receives `403 Forbidden` will prevent the entire request from going through. - * In particular, double-check that your user has permission to read `up*` if you are using UnifiedPush. + * In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush. From 1c32ee76130e358403de838898daa9f31e4863e9 Mon Sep 17 00:00:00 2001 From: Pixelguin <11445611+Pixelguin@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:36:56 -0800 Subject: [PATCH 048/548] Clarify wording --- docs/troubleshooting.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4b45a939..7cdd4cda 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -136,7 +136,8 @@ Sorry, there is no way to debug or get the logs from the iOS app (yet), outside If all of your topics are showing as "Reconnecting" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration: -* If ntfy is behind a reverse proxy, make sure `behind_proxy` is enabled. -* If ntfy is behind Nginx, make sure WebSockets are enabled. -* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON, so a single topic that receives `403 Forbidden` will prevent the entire request from going through. +* If ntfy is behind a reverse proxy (such as Nginx): + * Make sure `behind_proxy` is enabled in ntfy's config. + * Make sure WebSockets are enabled in the reverse proxy config. +* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON request, so a single topic that receives `403 Forbidden` will prevent the entire request from going through. * In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush. From aca9a774984789ca5b5e83cddd79cf42bc0e0e1f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 5 Jan 2026 21:14:29 -0500 Subject: [PATCH 049/548] Remove mtime --- server/message_cache.go | 37 ++++++++++--------------- server/message_cache_test.go | 22 ++------------- server/server.go | 6 ++-- server/types.go | 27 ++++++++++-------- web/public/static/langs/en.json | 3 +- web/public/sw.js | 3 -- web/src/app/SubscriptionManager.js | 41 +++++++++++++++------------- web/src/app/db.js | 4 +-- web/src/app/notificationUtils.js | 2 +- web/src/components/Notifications.jsx | 19 ++++--------- 10 files changed, 67 insertions(+), 97 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index 58080979..bd4dddd4 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -31,7 +31,6 @@ const ( mid TEXT NOT NULL, sid TEXT NOT NULL, time INT NOT NULL, - mtime INT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, @@ -57,7 +56,6 @@ const ( CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); CREATE INDEX IF NOT EXISTS idx_time ON messages (time); - CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); @@ -71,53 +69,53 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? AND published = 1 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND id > ? AND published = 1 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND (id > ? OR published = 0) - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sid, time, mtime, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE time <= ? AND published = 0 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` @@ -270,10 +268,8 @@ const ( //13 -> 14 migrate13To14AlterMessagesTableQuery = ` ALTER TABLE messages ADD COLUMN sid TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN mtime INT NOT NULL DEFAULT('0'); ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); - CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); ` ) @@ -415,7 +411,6 @@ func (c *messageCache) addMessages(ms []*message) error { m.ID, m.SID, m.Time, - m.MTime, m.Expires, m.Topic, m.Message, @@ -723,14 +718,13 @@ func readMessages(rows *sql.Rows) ([]*message, error) { } func readMessage(rows *sql.Rows) (*message, error) { - var timestamp, mtimestamp, expires, attachmentSize, attachmentExpires int64 + var timestamp, expires, attachmentSize, attachmentExpires int64 var priority, deleted int var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string err := rows.Scan( &id, &sid, ×tamp, - &mtimestamp, &expires, &topic, &msg, @@ -782,7 +776,6 @@ func readMessage(rows *sql.Rows) (*message, error) { ID: id, SID: sid, Time: timestamp, - MTime: mtimestamp, Expires: expires, Event: messageEvent, Topic: topic, diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 18f69fd3..64203136 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -24,11 +24,9 @@ func TestMemCache_Messages(t *testing.T) { func testCacheMessages(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = 1 - m1.MTime = 1000 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = 2 - m2.MTime = 2000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message"))) @@ -126,13 +124,10 @@ func testCacheMessagesScheduled(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m2 := newDefaultMessage("mytopic", "message 2") m2.Time = time.Now().Add(time.Hour).Unix() - m2.MTime = time.Now().Add(time.Hour).UnixMilli() m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! - m3.MTime = time.Now().Add(time.Minute).UnixMilli() // earlier than m2! + m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! m4 := newDefaultMessage("mytopic2", "message 4") m4.Time = time.Now().Add(time.Minute).Unix() - m4.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) require.Nil(t, c.AddMessage(m3)) @@ -206,25 +201,18 @@ func TestMemCache_MessagesSinceID(t *testing.T) { func testCacheMessagesSinceID(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m1.Time = 100 - m1.MTime = 100000 m2 := newDefaultMessage("mytopic", "message 2") m2.Time = 200 - m2.MTime = 200000 m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 - m3.MTime = time.Now().Add(time.Hour).UnixMilli() // Scheduled, in the future, later than m7 and m5 + m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 m4 := newDefaultMessage("mytopic", "message 4") m4.Time = 400 - m4.MTime = 400000 m5 := newDefaultMessage("mytopic", "message 5") - m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 - m5.MTime = time.Now().Add(time.Minute).UnixMilli() // Scheduled, in the future, later than m7 + m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 m6 := newDefaultMessage("mytopic", "message 6") m6.Time = 600 - m6.MTime = 600000 m7 := newDefaultMessage("mytopic", "message 7") m7.Time = 700 - m7.MTime = 700000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) @@ -285,17 +273,14 @@ func testCachePrune(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = now - 10 - m1.MTime = (now - 10) * 1000 m1.Expires = now - 5 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = now - 5 - m2.MTime = (now - 5) * 1000 m2.Expires = now + 5 // In the future m3 := newDefaultMessage("another_topic", "and another one") m3.Time = now - 12 - m3.MTime = (now - 12) * 1000 m3.Expires = now - 2 require.Nil(t, c.AddMessage(m1)) @@ -546,7 +531,6 @@ func TestSqliteCache_Migration_From1(t *testing.T) { // Add delayed message delayedMessage := newDefaultMessage("mytopic", "some delayed message") delayedMessage.Time = time.Now().Add(time.Minute).Unix() - delayedMessage.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(delayedMessage)) // 10, not 11! diff --git a/server/server.go b/server/server.go index 39c08c7d..9c612ebd 100644 --- a/server/server.go +++ b/server/server.go @@ -874,7 +874,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } minc(metricMessagesPublishedSuccess) - return s.writeJSON(w, m) + return s.writeJSON(w, m.forJSON()) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -1291,7 +1291,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(&msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } return buf.String(), nil @@ -1302,7 +1302,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v * func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(&msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } if msg.Event != messageEvent { diff --git a/server/types.go b/server/types.go index c8376673..467e80f5 100644 --- a/server/types.go +++ b/server/types.go @@ -24,10 +24,9 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - SID string `json:"sid"` // Message sequence ID for updating message contents - Time int64 `json:"time"` // Unix time in seconds - MTime int64 `json:"mtime"` // Unix time in milliseconds + ID string `json:"id"` // Random message ID + SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) + Time int64 `json:"time"` // Unix time in seconds Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Event string `json:"event"` // One of the above Topic string `json:"topic"` @@ -53,7 +52,6 @@ func (m *message) Context() log.Context { "message_id": m.ID, "message_sid": m.SID, "message_time": m.Time, - "message_mtime": m.MTime, "message_event": m.Event, "message_body_size": len(m.Message), } @@ -66,6 +64,16 @@ func (m *message) Context() log.Context { return fields } +// forJSON returns a copy of the message prepared for JSON output. +// It clears SID if it equals ID (to avoid redundant output). +func (m *message) forJSON() *message { + msg := *m + if msg.SID == msg.ID { + msg.SID = "" // Will be omitted due to omitempty + } + return &msg +} + type attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` @@ -123,7 +131,6 @@ func newMessage(event, topic, msg string) *message { return &message{ ID: util.RandomString(messageIDLength), Time: time.Now().Unix(), - MTime: time.Now().UnixMilli(), Event: event, Topic: topic, Message: msg, @@ -162,11 +169,7 @@ type sinceMarker struct { } func newSinceTime(timestamp int64) sinceMarker { - return newSinceMTime(timestamp * 1000) -} - -func newSinceMTime(mtimestamp int64) sinceMarker { - return sinceMarker{time.UnixMilli(mtimestamp), ""} + return sinceMarker{time.Unix(timestamp, 0), ""} } func newSinceID(id string) sinceMarker { @@ -557,7 +560,7 @@ func newWebPushPayload(subscriptionID string, message *message) *webPushPayload return &webPushPayload{ Event: webPushMessageEvent, SubscriptionID: subscriptionID, - Message: message, + Message: message.forJSON(), } } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 2094f0c2..0895b2eb 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -70,8 +70,7 @@ "notifications_delete": "Delete", "notifications_copied_to_clipboard": "Copied to clipboard", "notifications_tags": "Tags", - "notifications_sid": "Sequence ID", - "notifications_revisions": "Revisions", + "notifications_modified": "modified {{date}}", "notifications_priority_x": "Priority {{priority}}", "notifications_new_indicator": "New notification", "notifications_attachment_image": "Attachment image", diff --git a/web/public/sw.js b/web/public/sw.js index 471cbee2..e010e4d4 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -25,9 +25,6 @@ const addNotification = async ({ subscriptionId, message }) => { const db = await dbAsync(); const populatedMessage = message; - if (!("mtime" in populatedMessage)) { - populatedMessage.mtime = message.time * 1000; - } if (!("sid" in populatedMessage)) { populatedMessage.sid = message.id; } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 00d15d89..086fc048 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -157,7 +157,7 @@ class SubscriptionManager { // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach const notifications = await this.db.notifications - .orderBy("mtime") // Sort by time first + .orderBy("time") // Sort by time .filter((n) => n.subscriptionId === subscriptionId) .reverse() .toArray(); @@ -167,30 +167,39 @@ class SubscriptionManager { async getAllNotifications() { const notifications = await this.db.notifications - .orderBy("mtime") // Efficient, see docs + .orderBy("time") // Efficient, see docs .reverse() .toArray(); return this.groupNotificationsBySID(notifications); } - // Collapse notification updates based on sids + // Collapse notification updates based on sids, keeping only the latest version + // Also tracks the original time (earliest) for each sequence groupNotificationsBySID(notifications) { - const results = {}; + const latestBySid = {}; + const originalTimeBySid = {}; + notifications.forEach((notification) => { const key = `${notification.subscriptionId}:${notification.sid}`; - if (key in results) { - if ("history" in results[key]) { - results[key].history.push(notification); - } else { - results[key].history = [notification]; - } - } else { - results[key] = notification; + + // Track the latest notification for each sid (first one since sorted DESC) + if (!(key in latestBySid)) { + latestBySid[key] = notification; + } + + // Track the original (earliest) time for each sid + const currentOriginal = originalTimeBySid[key]; + if (currentOriginal === undefined || notification.time < currentOriginal) { + originalTimeBySid[key] = notification.time; } }); - return Object.values(results); + // Return latest notifications with originalTime set + return Object.entries(latestBySid).map(([key, notification]) => ({ + ...notification, + originalTime: originalTimeBySid[key], + })); } /** Adds notification, or returns false if it already exists */ @@ -201,9 +210,6 @@ class SubscriptionManager { } try { const populatedNotification = notification; - if (!("mtime" in populatedNotification)) { - populatedNotification.mtime = notification.time * 1000; - } if (!("sid" in populatedNotification)) { populatedNotification.sid = notification.id; } @@ -227,9 +233,6 @@ class SubscriptionManager { async addNotifications(subscriptionId, notifications) { const notificationsWithSubscriptionId = notifications.map((notification) => { const populatedNotification = notification; - if (!("mtime" in populatedNotification)) { - populatedNotification.mtime = notification.time * 1000; - } if (!("sid" in populatedNotification)) { populatedNotification.sid = notification.id; } diff --git a/web/src/app/db.js b/web/src/app/db.js index f4d36c1b..f11a5d0b 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,9 +11,9 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(3).stores({ + db.version(4).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,mtime,new,[subscriptionId+new]", // compound key for query performance + notifications: "&id,sid,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance users: "&baseUrl,username", prefs: "&key", }); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 2884e2f3..55d398c9 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -69,7 +69,7 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to badge, icon, image, - timestamp: message.mtime, + timestamp: message.time * 1000, tag, renotify: true, silent: false, diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 6872f46e..343a284a 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -236,7 +236,9 @@ const NotificationItem = (props) => { const { t, i18n } = useTranslation(); const { notification } = props; const { attachment } = notification; - const date = formatShortDateTime(notification.time, i18n.language); + const isModified = notification.originalTime && notification.originalTime !== notification.time; + const originalDate = formatShortDateTime(notification.originalTime || notification.time, i18n.language); + const modifiedDate = isModified ? formatShortDateTime(notification.time, i18n.language) : null; const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { @@ -267,8 +269,6 @@ const NotificationItem = (props) => { const hasUserActions = notification.actions && notification.actions.length > 0; const showActions = hasAttachmentActions || hasClickAction || hasUserActions; - const showSid = notification.id !== notification.sid || notification.history; - return ( @@ -289,7 +289,8 @@ const NotificationItem = (props) => { )} - {date} + {originalDate} + {modifiedDate && ` (${t("notifications_modified", { date: modifiedDate })})`} {[1, 2, 4, 5].includes(notification.priority) && ( { {t("notifications_tags")}: {tags} )} - {showSid && ( - - {t("notifications_sid")}: {notification.sid} - - )} - {notification.history && ( - - {t("notifications_revisions")}: {notification.history.length + 1} - - )} {showActions && ( From f51e99dc803ced7399730fd2560c3db00a979a57 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 5 Jan 2026 21:55:07 -0500 Subject: [PATCH 050/548] Remove modified --- web/public/static/langs/en.json | 1 - web/src/app/SubscriptionManager.js | 19 ++----------------- web/src/components/Notifications.jsx | 7 ++----- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 0895b2eb..b0d3c545 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -70,7 +70,6 @@ "notifications_delete": "Delete", "notifications_copied_to_clipboard": "Copied to clipboard", "notifications_tags": "Tags", - "notifications_modified": "modified {{date}}", "notifications_priority_x": "Priority {{priority}}", "notifications_new_indicator": "New notification", "notifications_attachment_image": "Attachment image", diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 086fc048..ccce5ccc 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -175,31 +175,16 @@ class SubscriptionManager { } // Collapse notification updates based on sids, keeping only the latest version - // Also tracks the original time (earliest) for each sequence groupNotificationsBySID(notifications) { const latestBySid = {}; - const originalTimeBySid = {}; - notifications.forEach((notification) => { const key = `${notification.subscriptionId}:${notification.sid}`; - - // Track the latest notification for each sid (first one since sorted DESC) + // Keep only the first (latest by time) notification for each sid if (!(key in latestBySid)) { latestBySid[key] = notification; } - - // Track the original (earliest) time for each sid - const currentOriginal = originalTimeBySid[key]; - if (currentOriginal === undefined || notification.time < currentOriginal) { - originalTimeBySid[key] = notification.time; - } }); - - // Return latest notifications with originalTime set - return Object.entries(latestBySid).map(([key, notification]) => ({ - ...notification, - originalTime: originalTimeBySid[key], - })); + return Object.values(latestBySid); } /** Adds notification, or returns false if it already exists */ diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 343a284a..94185b7c 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -236,9 +236,7 @@ const NotificationItem = (props) => { const { t, i18n } = useTranslation(); const { notification } = props; const { attachment } = notification; - const isModified = notification.originalTime && notification.originalTime !== notification.time; - const originalDate = formatShortDateTime(notification.originalTime || notification.time, i18n.language); - const modifiedDate = isModified ? formatShortDateTime(notification.time, i18n.language) : null; + const date = formatShortDateTime(notification.time, i18n.language); const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { @@ -289,8 +287,7 @@ const NotificationItem = (props) => { )} - {originalDate} - {modifiedDate && ` (${t("notifications_modified", { date: modifiedDate })})`} + {date} {[1, 2, 4, 5].includes(notification.priority) && ( Date: Mon, 5 Jan 2026 06:28:56 +0100 Subject: [PATCH 051/548] Translated using Weblate (Macedonian) Currently translated at 13.5% (55 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/ --- web/public/static/langs/mk.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/mk.json b/web/public/static/langs/mk.json index b1caef44..b407c512 100644 --- a/web/public/static/langs/mk.json +++ b/web/public/static/langs/mk.json @@ -50,5 +50,8 @@ "nav_topics_title": "Претплатени теми", "nav_button_all_notifications": "Сите нотификации", "nav_button_publish_message": "Објави нотификација", - "nav_button_subscribe": "Претплати се на тема" + "nav_button_subscribe": "Претплати се на тема", + "action_bar_unmute_notifications": "Одглуши ги нотификациите", + "action_bar_toggle_mute": "Заглуши/Загуши ги нотификациите", + "message_bar_publish": "Објави порака" } From 2856793effecc7c2038e259c8c26b9348edced70 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 6 Jan 2026 14:22:55 -0500 Subject: [PATCH 052/548] Deleted --- server/message_cache.go | 7 +++-- server/server.go | 48 ++++++++++++++++++++++++++++++ server/types.go | 12 ++++---- web/public/sw.js | 6 ++++ web/src/app/SubscriptionManager.js | 4 ++- web/src/app/db.js | 7 +++++ 6 files changed, 74 insertions(+), 10 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index bd4dddd4..589d06f8 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -75,7 +75,7 @@ const ( deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics - selectMessagesByIDQuery = ` + selectMessagesByIDQuery = ` SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE mid = ? @@ -431,7 +431,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, - 0, + m.Deleted, ) if err != nil { return err @@ -719,8 +719,9 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 - var priority, deleted int + var priority int var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string + var deleted bool err := rows.Scan( &id, &sid, diff --git a/server/server.go b/server/server.go index 9c612ebd..67fe328d 100644 --- a/server/server.go +++ b/server/server.go @@ -547,6 +547,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) + } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { @@ -902,6 +904,52 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v * return writeMatrixSuccess(w) } +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + t, err := fromContext[*topic](r, contextTopic) + if err != nil { + return err + } + vrate, err := fromContext[*visitor](r, contextRateVisitor) + if err != nil { + return err + } + if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { + return errHTTPTooManyRequestsLimitMessages.With(t) + } + sid, e := s.sidFromPath(r.URL.Path) + if e != nil { + return e.With(t) + } + // Create a delete message: empty body, same SID, deleted flag set + m := newDefaultMessage(t.ID, "") + m.SID = sid + m.Deleted = true + 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 { + 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) + } + // Add to message cache + if err := s.messageCache.AddMessage(m); err != nil { + return err + } + logvrm(v, r, m).Tag(tagPublish).Debug("Deleted message with SID %s", sid) + s.mu.Lock() + s.messages++ + s.mu.Unlock() + return s.writeJSON(w, m.forJSON()) +} + func (s *Server) sendToFirebase(v *visitor, m *message) { logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") if err := s.firebaseClient.Send(v, m); err != nil { diff --git a/server/types.go b/server/types.go index 467e80f5..88110b0d 100644 --- a/server/types.go +++ b/server/types.go @@ -24,14 +24,14 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) - Time int64 `json:"time"` // Unix time in seconds + ID string `json:"id"` // Random message ID + SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) + Time int64 `json:"time"` // Unix time in seconds Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Event string `json:"event"` // One of the above Topic string `json:"topic"` Title string `json:"title,omitempty"` - Message string `json:"message,omitempty"` + Message string `json:"message"` // Allow empty message body Priority int `json:"priority,omitempty"` Tags []string `json:"tags,omitempty"` Click string `json:"click,omitempty"` @@ -40,10 +40,10 @@ type message struct { Attachment *attachment `json:"attachment,omitempty"` PollID string `json:"poll_id,omitempty"` ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown - Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes + Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes + Deleted bool `json:"deleted,omitempty"` // True if message is marked as deleted Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting User string `json:"-"` // UserID of the uploader, used to associated attachments - Deleted int `json:"deleted,omitempty"` } func (m *message) Context() log.Context { diff --git a/web/public/sw.js b/web/public/sw.js index e010e4d4..33e97da8 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -57,6 +57,12 @@ const handlePushMessage = async (data) => { broadcastChannel.postMessage(message); // To potentially play sound await addNotification({ subscriptionId, message }); + + // Don't show a notification for deleted messages + if (message.deleted) { + return; + } + await self.registration.showNotification( ...toNotificationParams({ subscriptionId, diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index ccce5ccc..7d917e2d 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -175,6 +175,7 @@ class SubscriptionManager { } // Collapse notification updates based on sids, keeping only the latest version + // Filters out notifications where the latest in the sequence is deleted groupNotificationsBySID(notifications) { const latestBySid = {}; notifications.forEach((notification) => { @@ -184,7 +185,8 @@ class SubscriptionManager { latestBySid[key] = notification; } }); - return Object.values(latestBySid); + // Filter out notifications where the latest is deleted + return Object.values(latestBySid).filter((n) => !n.deleted); } /** Adds notification, or returns false if it already exists */ diff --git a/web/src/app/db.js b/web/src/app/db.js index f11a5d0b..0391388d 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -18,6 +18,13 @@ const createDatabase = (username) => { prefs: "&key", }); + db.version(5).stores({ + subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", + notifications: "&id,sid,subscriptionId,time,new,deleted,[subscriptionId+new]", // added deleted index + users: "&baseUrl,username", + prefs: "&key", + }); + return db; }; From 2dd152df3f198f524c31bc2f42242ef6eb61fb81 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 6 Jan 2026 18:02:08 -0500 Subject: [PATCH 053/548] Manual fixes for AI slop --- go.mod | 14 +- go.sum | 28 +-- server/server.go | 5 +- web/package-lock.json | 257 ++++++++++++++++----------- web/public/sw.js | 19 +- web/src/app/SubscriptionManager.js | 56 +++--- web/src/app/db.js | 12 +- web/src/app/notificationUtils.js | 10 +- web/src/app/utils.js | 7 + web/src/components/Notifications.jsx | 28 +-- web/src/components/hooks.js | 15 +- 11 files changed, 255 insertions(+), 196 deletions(-) diff --git a/go.mod b/go.mod index e6d91101..2b80f31b 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/emersion/go-smtp v0.18.0 github.com/gabriel-vasile/mimetype v1.4.12 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.32 + github.com/mattn/go-sqlite3 v1.14.33 github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 @@ -21,7 +21,7 @@ require ( golang.org/x/sync v0.19.0 golang.org/x/term v0.38.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.258.0 + google.golang.org/api v0.259.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -76,7 +76,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.67.4 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect @@ -95,10 +95,10 @@ require ( golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/grpc v1.77.0 // indirect + google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index babf7049..56e08d3b 100644 --- a/go.sum +++ b/go.sum @@ -112,8 +112,8 @@ 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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= @@ -131,8 +131,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -263,18 +263,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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc= -google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww= +google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= +google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= 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-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM= -google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b h1:kqShdsddZrS6q+DGBCA73CzHsKDu5vW4qw78tFnbVvY= +google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:gw1DtiPCt5uh/HV9STVEeaO00S5ATsJiJ2LsZV8lcDI= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= 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= diff --git a/server/server.go b/server/server.go index 67fe328d..40a15e30 100644 --- a/server/server.go +++ b/server/server.go @@ -139,7 +139,8 @@ var ( const ( firebaseControlTopic = "~control" // See Android if changed firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now) - emptyMessageBody = "triggered" // Used if message body is empty + emptyMessageBody = "triggered" // Used when a message body is empty + deletedMessageBody = "deleted" // Used when a message is deleted newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages @@ -921,7 +922,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor return e.With(t) } // Create a delete message: empty body, same SID, deleted flag set - m := newDefaultMessage(t.ID, "") + m := newDefaultMessage(t.ID, deletedMessageBody) m.SID = sid m.Deleted = true m.Sender = v.IP() diff --git a/web/package-lock.json b/web/package-lock.json index 789b95f7..75bdd453 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2198,9 +2198,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2798,9 +2798,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "cpu": [ "arm" ], @@ -2812,9 +2812,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "cpu": [ "arm64" ], @@ -2826,9 +2826,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "cpu": [ "arm64" ], @@ -2840,9 +2840,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "cpu": [ "x64" ], @@ -2854,9 +2854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", "cpu": [ "arm64" ], @@ -2868,9 +2868,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", "cpu": [ "x64" ], @@ -2882,9 +2882,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "cpu": [ "arm" ], @@ -2896,9 +2896,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "cpu": [ "arm" ], @@ -2910,9 +2910,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "cpu": [ "arm64" ], @@ -2924,9 +2924,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "cpu": [ "arm64" ], @@ -2938,9 +2938,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", "cpu": [ "loong64" ], @@ -2952,9 +2966,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "cpu": [ "ppc64" ], @@ -2966,9 +2994,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", "cpu": [ "riscv64" ], @@ -2980,9 +3008,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "cpu": [ "riscv64" ], @@ -2994,9 +3022,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "cpu": [ "s390x" ], @@ -3008,9 +3036,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "cpu": [ "x64" ], @@ -3022,9 +3050,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "cpu": [ "x64" ], @@ -3035,10 +3063,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "cpu": [ "arm64" ], @@ -3050,9 +3092,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "cpu": [ "arm64" ], @@ -3064,9 +3106,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "cpu": [ "ia32" ], @@ -3078,9 +3120,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "cpu": [ "x64" ], @@ -3092,9 +3134,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "cpu": [ "x64" ], @@ -3566,9 +3608,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", - "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3781,9 +3823,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", "dev": true, "funding": [ { @@ -4872,9 +4914,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4969,9 +5011,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -7586,9 +7628,9 @@ } }, "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", "dependencies": { @@ -7602,28 +7644,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" } }, diff --git a/web/public/sw.js b/web/public/sw.js index 33e97da8..3370cd83 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -8,6 +8,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; +import { messageWithSID } from "../src/app/utils"; /** * General docs for service workers and PWAs: @@ -23,19 +24,17 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast"); const addNotification = async ({ subscriptionId, message }) => { const db = await dbAsync(); - const populatedMessage = message; - if (!("sid" in populatedMessage)) { - populatedMessage.sid = message.id; - } + // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too + // Add notification to database await db.notifications.add({ - ...populatedMessage, + ...messageWithSID(message), subscriptionId, - // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - new: 1, + new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); + // Update subscription last message id (for ?since=... queries) await db.subscriptions.update(subscriptionId, { last: message.id, }); @@ -54,8 +53,7 @@ const addNotification = async ({ subscriptionId, message }) => { const handlePushMessage = async (data) => { const { subscription_id: subscriptionId, message } = data; - broadcastChannel.postMessage(message); // To potentially play sound - + // Add notification to database await addNotification({ subscriptionId, message }); // Don't show a notification for deleted messages @@ -63,6 +61,9 @@ const handlePushMessage = async (data) => { return; } + // Broadcast the message to potentially play a sound + broadcastChannel.postMessage(message); + await self.registration.showNotification( ...toNotificationParams({ subscriptionId, diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 7d917e2d..f5ea5a53 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -2,7 +2,7 @@ import api from "./Api"; import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; -import { topicUrl } from "./utils"; +import { messageWithSID, topicUrl } from "./utils"; class SubscriptionManager { constructor(dbImpl) { @@ -15,7 +15,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() })) ); } @@ -48,16 +48,17 @@ class SubscriptionManager { } async notify(subscriptionId, notification) { + if (notification.deleted) { + return; + } const subscription = await this.get(subscriptionId); if (subscription.mutedUntil > 0) { return; } - const priority = notification.priority ?? 3; if (priority < (await prefs.minPriority())) { return; } - await notifier.notify(subscription, notification); } @@ -82,7 +83,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null, + last: null }; await this.db.subscriptions.put(subscription); @@ -100,7 +101,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation, // May be null! + reservation // May be null! }); return local.id; @@ -196,19 +197,20 @@ class SubscriptionManager { return false; } try { - const populatedNotification = notification; - if (!("sid" in populatedNotification)) { - populatedNotification.sid = notification.id; - } - // sw.js duplicates this logic, so if you change it here, change it there too + // Note: Service worker (sw.js) and addNotifications() duplicates this logic, + // so if you change it here, change it there too. + + // Add notification to database await this.db.notifications.add({ - ...populatedNotification, + ...messageWithSID(notification), subscriptionId, - // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - new: 1, - }); // FIXME consider put() for double tab + new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + }); + + // FIXME consider put() for double tab + // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id, + last: notification.id }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -219,16 +221,12 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { const notificationsWithSubscriptionId = notifications.map((notification) => { - const populatedNotification = notification; - if (!("sid" in populatedNotification)) { - populatedNotification.sid = notification.id; - } - return { ...populatedNotification, subscriptionId }; + return { ...messageWithSID(notification), subscriptionId }; }); const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId, + last: lastNotificationId }); } @@ -249,6 +247,10 @@ class SubscriptionManager { await this.db.notifications.delete(notificationId); } + async deleteNotificationBySid(subscriptionId, sid) { + await this.db.notifications.where({ subscriptionId, sid }).delete(); + } + async deleteNotifications(subscriptionId) { await this.db.notifications.where({ subscriptionId }).delete(); } @@ -257,25 +259,29 @@ class SubscriptionManager { await this.db.notifications.where({ id: notificationId }).modify({ new: 0 }); } + async markNotificationReadBySid(subscriptionId, sid) { + await this.db.notifications.where({ subscriptionId, sid }).modify({ new: 0 }); + } + async markNotificationsRead(subscriptionId) { await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 }); } async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil, + mutedUntil }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName, + displayName }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation, + reservation }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index 0391388d..1bda553f 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,16 +11,10 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(4).stores({ + db.version(6).stores({ + // FIXME Should be 3 subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance - users: "&baseUrl,username", - prefs: "&key", - }); - - db.version(5).stores({ - subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,new,deleted,[subscriptionId+new]", // added deleted index + notifications: "&id,sid,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sid]", users: "&baseUrl,username", prefs: "&key", }); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 55d398c9..6cb8bc37 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -53,14 +53,6 @@ export const badge = "/static/images/mask-icon.svg"; export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => { const image = isImage(message.attachment) ? message.attachment.url : undefined; - let tag; - - if (message.sid) { - tag = message.sid; - } else { - tag = subscriptionId; - } - // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API return [ formatTitleWithDefault(message, defaultTitle), @@ -70,7 +62,7 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to icon, image, timestamp: message.time * 1000, - tag, + tag: message.sid || message.id, // Update notification if there is a sequence ID renotify: true, silent: false, // This is used by the notification onclick event diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 935f2024..d9f851b4 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -103,6 +103,13 @@ export const maybeActionErrors = (notification) => { return actionErrors; }; +export const messageWithSID = (message) => { + if (!message.sid) { + message.sid = message.id; + } + return message; +}; + export const shuffle = (arr) => { const returnArr = [...arr]; diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 94185b7c..53c9085f 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -240,22 +240,22 @@ const NotificationItem = (props) => { const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { - console.log(`[Notifications] Deleting notification ${notification.id}`); - await subscriptionManager.deleteNotification(notification.id); - notification.history?.forEach(async (revision) => { - console.log(`[Notifications] Deleting revision ${revision.id}`); - await subscriptionManager.deleteNotification(revision.id); - }); + if (notification.sid) { + console.log(`[Notifications] Deleting all notifications with sid ${notification.sid}`); + await subscriptionManager.deleteNotificationBySid(notification.subscriptionId, notification.sid); + } else { + console.log(`[Notifications] Deleting notification ${notification.id}`); + await subscriptionManager.deleteNotification(notification.id); + } }; const handleMarkRead = async () => { - console.log(`[Notifications] Marking notification ${notification.id} as read`); - await subscriptionManager.markNotificationRead(notification.id); - notification.history - ?.filter((revision) => revision.new === 1) - .forEach(async (revision) => { - console.log(`[Notifications] Marking revision ${revision.id} as read`); - await subscriptionManager.markNotificationRead(revision.id); - }); + if (notification.sid) { + console.log(`[Notifications] Marking notification with sid ${notification.sid} as read`); + await subscriptionManager.markNotificationReadBySid(notification.subscriptionId, notification.sid); + } else { + console.log(`[Notifications] Marking notification ${notification.id} as read`); + await subscriptionManager.markNotificationRead(notification.id); + } }; const handleCopy = (s) => { copyToClipboard(s); diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 519d4c6a..4d852140 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -50,12 +50,23 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNotification = async (subscriptionId, notification) => { + if (notification.deleted && notification.sid) { + return handleDeletedNotification(subscriptionId, notification); + } + return handleNewOrUpdatedNotification(subscriptionId, notification); + }; + + const handleNewOrUpdatedNotification = async (subscriptionId, notification) => { const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { await subscriptionManager.notify(subscriptionId, notification); } }; + const handleDeletedNotification = async (subscriptionId, notification) => { + await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); + }; + const handleMessage = async (subscriptionId, message) => { const subscription = await subscriptionManager.get(subscriptionId); @@ -231,7 +242,9 @@ export const useIsLaunchedPWA = () => { useEffect(() => { if (isIOSStandalone) { - return () => {}; // No need to listen for events on iOS + return () => { + // No need to listen for events on iOS + }; } const handler = (evt) => { console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`); From 544f7ef1cbcb2ad475b868b299c8c687ac23b29c Mon Sep 17 00:00:00 2001 From: cyberboh Date: Tue, 6 Jan 2026 14:49:16 +0100 Subject: [PATCH 054/548] Translated using Weblate (Indonesian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/ --- web/public/static/langs/id.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index a149e570..c63d9a12 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -50,7 +50,7 @@ "publish_dialog_progress_uploading": "Mengunggah …", "notifications_more_details": "Untuk informasi lanjut, lihat situs web atau dokumentasi.", "publish_dialog_progress_uploading_detail": "Mengunggah {{loaded}}/{{total}} ({{percent}}%) …", - "publish_dialog_message_published": "Notifikasi terpublikasi", + "publish_dialog_message_published": "Notifikasi dipublikasi", "notifications_loading": "Memuat notifikasi …", "publish_dialog_base_url_label": "URL Layanan", "publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk", @@ -71,9 +71,9 @@ "publish_dialog_priority_high": "Prioritas tinggi", "publish_dialog_priority_max": "Prioritas maksimal", "publish_dialog_topic_label": "Nama topik", - "publish_dialog_message_placeholder": "Ketik sebuah pesan di sini", + "publish_dialog_message_placeholder": "Tulis pesan di sini", "publish_dialog_click_label": "Klik URL", - "publish_dialog_tags_placeholder": "Daftar tanda yang dipisah dengan koma, mis. peringatan, cadangan-srv1", + "publish_dialog_tags_placeholder": "Daftar label yang dipisah dengan tanda koma, contoh: peringatan, cadangan-srv1", "publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik", "publish_dialog_email_label": "Email", "publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, mis. andi@contoh.com", @@ -404,5 +404,7 @@ "web_push_subscription_expiring_title": "Notifikasi akan dijeda", "web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi", "web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server", - "web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web" + "web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web", + "account_basics_cannot_edit_or_delete_provisioned_user": "Pengguna yang telah ditetapkan tidak dapat diedit atau dihapus", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Tidak dapat mengedit atau menghapus token yang disediakan" } From b3721c1b7148bc65d7c94ac5fac2576f50306181 Mon Sep 17 00:00:00 2001 From: "Kristijan \\\"Fremen\\\" Velkovski" Date: Wed, 7 Jan 2026 07:29:49 +0100 Subject: [PATCH 055/548] Translated using Weblate (Macedonian) Currently translated at 14.7% (60 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/ --- web/public/static/langs/mk.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/mk.json b/web/public/static/langs/mk.json index b407c512..56417357 100644 --- a/web/public/static/langs/mk.json +++ b/web/public/static/langs/mk.json @@ -53,5 +53,10 @@ "nav_button_subscribe": "Претплати се на тема", "action_bar_unmute_notifications": "Одглуши ги нотификациите", "action_bar_toggle_mute": "Заглуши/Загуши ги нотификациите", - "message_bar_publish": "Објави порака" + "message_bar_publish": "Објави порака", + "nav_button_connecting": "се конектира", + "nav_upgrade_banner_label": "Надградете на ntfy Pro", + "nav_upgrade_banner_description": "Резервирајте теми, повеќе пораки и е-пораки и поголеми прилози", + "alert_notification_permission_required_title": "Известувањата се исклучени", + "alert_notification_permission_required_description": "Дајте му дозвола на вашиот прелистувач да прикажува известувања" } From bfbe73aea36f44208f5795849b70ebabcd803a30 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 7 Jan 2026 09:46:08 -0500 Subject: [PATCH 056/548] Update polling --- web/src/app/Poller.js | 34 ++++++++++++++++++++++++++---- web/src/app/SubscriptionManager.js | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 2261dddc..8fefc94e 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -42,12 +42,22 @@ class Poller { const since = subscription.last; const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - if (!notifications || notifications.length === 0) { + const deletedSids = this.deletedSids(notifications); + const newOrUpdatedNotifications = this.newOrUpdatedNotifications(notifications, deletedSids); + + // Delete all existing notifications with a deleted sequence ID + if (deletedSids.length > 0) { + console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`); + await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); + } + + // Add new or updated notifications + if (newOrUpdatedNotifications.length > 0) { + console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); + await subscriptionManager.addNotifications(subscription.id, notifications); + } else { console.log(`[Poller] No new notifications found for ${subscription.id}`); - return; } - console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); - await subscriptionManager.addNotifications(subscription.id, notifications); } pollInBackground(subscription) { @@ -59,6 +69,22 @@ class Poller { } })(); } + + deletedSids(notifications) { + return new Set( + notifications + .filter(n => n.sid && n.deleted) + .map(n => n.sid) + ); + } + + newOrUpdatedNotifications(notifications, deletedSids) { + return notifications + .filter((notification) => { + const sid = notification.sid || notification.id; + return !deletedSids.has(notification.id) && !deletedSids.has(sid) && !notification.deleted; + }); + } } const poller = new Poller(); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index f5ea5a53..2b9260f7 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -193,7 +193,7 @@ class SubscriptionManager { /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); - if (exists) { + if (exists || notification.deleted) { return false; } try { From 75abf2e245e7c5fa22b55f9cf437c58b180753e0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 10:28:02 -0500 Subject: [PATCH 057/548] Delete old messages with SID when new messages arrive --- web/public/sw.js | 5 ++++ web/src/app/Poller.js | 42 ++++++++++++++++-------------- web/src/app/SubscriptionManager.js | 26 ++++++++---------- web/src/components/hooks.js | 5 ++++ 4 files changed, 43 insertions(+), 35 deletions(-) diff --git a/web/public/sw.js b/web/public/sw.js index 3370cd83..3a67da58 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -27,6 +27,11 @@ const addNotification = async ({ subscriptionId, message }) => { // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too + // Delete existing notification with same SID (if any) + if (message.sid) { + await db.notifications.where({ subscriptionId, sid: message.sid }).delete(); + } + // Add notification to database await db.notifications.add({ ...messageWithSID(message), diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 8fefc94e..9f3ff6a0 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -42,19 +42,22 @@ class Poller { const since = subscription.last; const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - const deletedSids = this.deletedSids(notifications); - const newOrUpdatedNotifications = this.newOrUpdatedNotifications(notifications, deletedSids); + const latestBySid = this.latestNotificationsBySid(notifications); // Delete all existing notifications with a deleted sequence ID + const deletedSids = Object.entries(latestBySid) + .filter(([, notification]) => notification.deleted) + .map(([sid]) => sid); if (deletedSids.length > 0) { console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`); await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); } - // Add new or updated notifications - if (newOrUpdatedNotifications.length > 0) { - console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); - await subscriptionManager.addNotifications(subscription.id, notifications); + // Add only the latest notification for each non-deleted sequence + const notificationsToAdd = Object.values(latestBySid).filter((n) => !n.deleted); + if (notificationsToAdd.length > 0) { + console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); + await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); } else { console.log(`[Poller] No new notifications found for ${subscription.id}`); } @@ -70,20 +73,19 @@ class Poller { })(); } - deletedSids(notifications) { - return new Set( - notifications - .filter(n => n.sid && n.deleted) - .map(n => n.sid) - ); - } - - newOrUpdatedNotifications(notifications, deletedSids) { - return notifications - .filter((notification) => { - const sid = notification.sid || notification.id; - return !deletedSids.has(notification.id) && !deletedSids.has(sid) && !notification.deleted; - }); + /** + * Groups notifications by sid and returns only the latest (highest time) for each sequence. + * Returns an object mapping sid -> latest notification. + */ + latestNotificationsBySid(notifications) { + const latestBySid = {}; + notifications.forEach((notification) => { + const sid = notification.sid || notification.id; + if (!(sid in latestBySid) || notification.time >= latestBySid[sid].time) { + latestBySid[sid] = notification; + } + }); + return latestBySid; } } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 2b9260f7..40cc475a 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -15,7 +15,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), })) ); } @@ -83,7 +83,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null + last: null, }; await this.db.subscriptions.put(subscription); @@ -101,7 +101,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation // May be null! + reservation, // May be null! }); return local.id; @@ -157,22 +157,18 @@ class SubscriptionManager { // It's actually fine, because the reading and filtering is quite fast. The rendering is what's // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach - const notifications = await this.db.notifications + return this.db.notifications .orderBy("time") // Sort by time .filter((n) => n.subscriptionId === subscriptionId) .reverse() .toArray(); - - return this.groupNotificationsBySID(notifications); } async getAllNotifications() { - const notifications = await this.db.notifications + return this.db.notifications .orderBy("time") // Efficient, see docs .reverse() .toArray(); - - return this.groupNotificationsBySID(notifications); } // Collapse notification updates based on sids, keeping only the latest version @@ -204,13 +200,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSID(notification), subscriptionId, - new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id + last: notification.id, }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -226,7 +222,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId + last: lastNotificationId, }); } @@ -269,19 +265,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil + mutedUntil, }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName + displayName, }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation + reservation, }); } diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 4d852140..588697fc 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -57,6 +57,11 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNewOrUpdatedNotification = async (subscriptionId, notification) => { + // Delete existing notification with same sid, if any + if (notification.sid) { + await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); + } + // Add notification to database const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { await subscriptionManager.notify(subscriptionId, notification); From 239959e2a4bc2a3f39b407414cf8d4a22c0706aa Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 11:19:53 -0500 Subject: [PATCH 058/548] Revert some changes; make poller respect deleteAfter pref --- web/src/app/Poller.js | 14 +++++++++--- web/src/app/SubscriptionManager.js | 33 ++++++++-------------------- web/src/components/Notifications.jsx | 18 ++++----------- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 9f3ff6a0..5c7d2e2d 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -1,4 +1,5 @@ import api from "./Api"; +import prefs from "./Prefs"; import subscriptionManager from "./SubscriptionManager"; const delayMillis = 2000; // 2 seconds @@ -42,14 +43,21 @@ class Poller { const since = subscription.last; const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - const latestBySid = this.latestNotificationsBySid(notifications); - // Delete all existing notifications with a deleted sequence ID + // Filter out notifications older than the prune threshold + const deleteAfterSeconds = await prefs.deleteAfter(); + const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0; + const recentNotifications = pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications; + + // Find the latest notification for each sequence ID + const latestBySid = this.latestNotificationsBySid(recentNotifications); + + // Delete all existing notifications for which the latest notification is marked as deleted const deletedSids = Object.entries(latestBySid) .filter(([, notification]) => notification.deleted) .map(([sid]) => sid); if (deletedSids.length > 0) { - console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`); + console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSids); await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 40cc475a..4a4c6f54 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -15,7 +15,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() })) ); } @@ -83,7 +83,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null, + last: null }; await this.db.subscriptions.put(subscription); @@ -101,7 +101,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation, // May be null! + reservation // May be null! }); return local.id; @@ -171,21 +171,6 @@ class SubscriptionManager { .toArray(); } - // Collapse notification updates based on sids, keeping only the latest version - // Filters out notifications where the latest in the sequence is deleted - groupNotificationsBySID(notifications) { - const latestBySid = {}; - notifications.forEach((notification) => { - const key = `${notification.subscriptionId}:${notification.sid}`; - // Keep only the first (latest by time) notification for each sid - if (!(key in latestBySid)) { - latestBySid[key] = notification; - } - }); - // Filter out notifications where the latest is deleted - return Object.values(latestBySid).filter((n) => !n.deleted); - } - /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); @@ -200,13 +185,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSID(notification), subscriptionId, - new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id, + last: notification.id }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -222,7 +207,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId, + last: lastNotificationId }); } @@ -265,19 +250,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil, + mutedUntil }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName, + displayName }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation, + reservation }); } diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 53c9085f..449b238b 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -240,22 +240,12 @@ const NotificationItem = (props) => { const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { - if (notification.sid) { - console.log(`[Notifications] Deleting all notifications with sid ${notification.sid}`); - await subscriptionManager.deleteNotificationBySid(notification.subscriptionId, notification.sid); - } else { - console.log(`[Notifications] Deleting notification ${notification.id}`); - await subscriptionManager.deleteNotification(notification.id); - } + console.log(`[Notifications] Deleting notification ${notification.id}`); + await subscriptionManager.deleteNotification(notification.id); }; const handleMarkRead = async () => { - if (notification.sid) { - console.log(`[Notifications] Marking notification with sid ${notification.sid} as read`); - await subscriptionManager.markNotificationReadBySid(notification.subscriptionId, notification.sid); - } else { - console.log(`[Notifications] Marking notification ${notification.id} as read`); - await subscriptionManager.markNotificationRead(notification.id); - } + console.log(`[Notifications] Marking notification ${notification.id} as read`); + await subscriptionManager.markNotificationRead(notification.id); }; const handleCopy = (s) => { copyToClipboard(s); From dffee9ea7d64cfb928ed31d1efb61490252c6d57 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 11:39:32 -0500 Subject: [PATCH 059/548] Remove forJSON --- server/message_cache.go | 4 ++++ server/server.go | 8 ++++---- server/types.go | 12 +----------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index 589d06f8..1752c2c9 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -773,6 +773,10 @@ func readMessage(rows *sql.Rows) (*message, error) { URL: attachmentURL, } } + // Clear SID if it equals ID (we do not want the SID in the message output) + if sid == id { + sid = "" + } return &message{ ID: id, SID: sid, diff --git a/server/server.go b/server/server.go index 40a15e30..edae9f2a 100644 --- a/server/server.go +++ b/server/server.go @@ -877,7 +877,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } minc(metricMessagesPublishedSuccess) - return s.writeJSON(w, m.forJSON()) + return s.writeJSON(w, m) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -948,7 +948,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor s.mu.Lock() s.messages++ s.mu.Unlock() - return s.writeJSON(w, m.forJSON()) + return s.writeJSON(w, m) } func (s *Server) sendToFirebase(v *visitor, m *message) { @@ -1340,7 +1340,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { + if err := json.NewEncoder(&buf).Encode(msg); err != nil { return "", err } return buf.String(), nil @@ -1351,7 +1351,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v * func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { + if err := json.NewEncoder(&buf).Encode(msg); err != nil { return "", err } if msg.Event != messageEvent { diff --git a/server/types.go b/server/types.go index 88110b0d..b68721a3 100644 --- a/server/types.go +++ b/server/types.go @@ -64,16 +64,6 @@ func (m *message) Context() log.Context { return fields } -// forJSON returns a copy of the message prepared for JSON output. -// It clears SID if it equals ID (to avoid redundant output). -func (m *message) forJSON() *message { - msg := *m - if msg.SID == msg.ID { - msg.SID = "" // Will be omitted due to omitempty - } - return &msg -} - type attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` @@ -560,7 +550,7 @@ func newWebPushPayload(subscriptionID string, message *message) *webPushPayload return &webPushPayload{ Event: webPushMessageEvent, SubscriptionID: subscriptionID, - Message: message.forJSON(), + Message: message, } } From 7cffbfcd6d78c02df0e9b1da2353451f359ed8f1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 13:43:57 -0500 Subject: [PATCH 060/548] Simplify handleNotifications --- web/src/components/hooks.js | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 588697fc..9f836156 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -50,26 +50,18 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNotification = async (subscriptionId, notification) => { - if (notification.deleted && notification.sid) { - return handleDeletedNotification(subscriptionId, notification); - } - return handleNewOrUpdatedNotification(subscriptionId, notification); - }; - - const handleNewOrUpdatedNotification = async (subscriptionId, notification) => { // Delete existing notification with same sid, if any if (notification.sid) { await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); } - // Add notification to database - const added = await subscriptionManager.addNotification(subscriptionId, notification); - if (added) { - await subscriptionManager.notify(subscriptionId, notification); - } - }; - const handleDeletedNotification = async (subscriptionId, notification) => { - await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); + // Add notification to database + if (!notification.deleted) { + const added = await subscriptionManager.addNotification(subscriptionId, notification); + if (added) { + await subscriptionManager.notify(subscriptionId, notification); + } + } }; const handleMessage = async (subscriptionId, message) => { From fd8cd5ca91560dee8c63cdff5bf96f1914129a4c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 13:45:05 -0500 Subject: [PATCH 061/548] Comment --- web/src/components/hooks.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 9f836156..3b171eab 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -50,11 +50,13 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNotification = async (subscriptionId, notification) => { + // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() + // and FirebaseService::handleMessage(). + // Delete existing notification with same sid, if any if (notification.sid) { await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); } - // Add notification to database if (!notification.deleted) { const added = await subscriptionManager.addNotification(subscriptionId, notification); From 1ab7ca876c978f230dd0cd0ed26ffd394b781116 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 14:27:18 -0500 Subject: [PATCH 062/548] Rename to sequence_id --- server/errors.go | 2 +- server/message_cache.go | 38 ++++++++++---------- server/message_cache_test.go | 14 ++++---- server/server.go | 26 +++++++------- server/server_test.go | 10 +++--- server/types.go | 56 +++++++++++++++--------------- web/public/sw.js | 11 +++--- web/src/app/Poller.js | 35 ++++++++++--------- web/src/app/SubscriptionManager.js | 32 ++++++++--------- web/src/app/db.js | 7 ++-- web/src/app/notificationUtils.js | 2 +- web/src/app/utils.js | 6 ++-- web/src/components/hooks.js | 7 ++-- 13 files changed, 125 insertions(+), 121 deletions(-) diff --git a/server/errors.go b/server/errors.go index 302c06a7..950d23fc 100644 --- a/server/errors.go +++ b/server/errors.go @@ -125,7 +125,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: SID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} + errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", 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} diff --git a/server/message_cache.go b/server/message_cache.go index 1752c2c9..c00c67c8 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -29,7 +29,7 @@ const ( CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, mid TEXT NOT NULL, - sid TEXT NOT NULL, + sequence_id TEXT NOT NULL, time INT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, @@ -54,7 +54,7 @@ const ( deleted INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); - CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); + CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); CREATE INDEX IF NOT EXISTS idx_time ON messages (time); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); @@ -69,50 +69,50 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) + INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id @@ -267,9 +267,9 @@ const ( //13 -> 14 migrate13To14AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN sid TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); - CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); + CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); ` ) @@ -409,7 +409,7 @@ func (c *messageCache) addMessages(ms []*message) error { } _, err := stmt.Exec( m.ID, - m.SID, + m.SequenceID, m.Time, m.Expires, m.Topic, @@ -720,11 +720,11 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 var priority int - var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string + var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string var deleted bool err := rows.Scan( &id, - &sid, + &sequenceID, ×tamp, &expires, &topic, @@ -773,13 +773,13 @@ func readMessage(rows *sql.Rows) (*message, error) { URL: attachmentURL, } } - // Clear SID if it equals ID (we do not want the SID in the message output) - if sid == id { - sid = "" + // Clear SequenceID if it equals ID (we do not want the SequenceID in the message output) + if sequenceID == id { + sequenceID = "" } return &message{ ID: id, - SID: sid, + SequenceID: sequenceID, Time: timestamp, Expires: expires, Event: messageEvent, diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 64203136..1e285605 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -319,7 +319,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" - m.SID = "m1" + m.SequenceID = "m1" m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "flower.jpg", @@ -333,7 +333,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires2 := time.Now().Add(2 * time.Hour).Unix() // Future m = newDefaultMessage("mytopic", "sending you a car") m.ID = "m2" - m.SID = "m2" + m.SequenceID = "m2" m.Sender = netip.MustParseAddr("1.2.3.4") m.Attachment = &attachment{ Name: "car.jpg", @@ -347,7 +347,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) { expires3 := time.Now().Add(1 * time.Hour).Unix() // Future m = newDefaultMessage("another-topic", "sending you another car") m.ID = "m3" - m.SID = "m3" + m.SequenceID = "m3" m.User = "u_BAsbaAa" m.Sender = netip.MustParseAddr("5.6.7.8") m.Attachment = &attachment{ @@ -403,13 +403,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) { func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m := newDefaultMessage("mytopic", "flower for you") m.ID = "m1" - m.SID = "m1" + m.SequenceID = "m1" m.Expires = time.Now().Add(time.Hour).Unix() require.Nil(t, c.AddMessage(m)) m = newDefaultMessage("mytopic", "message with attachment") m.ID = "m2" - m.SID = "m2" + m.SequenceID = "m2" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "car.jpg", @@ -422,7 +422,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m = newDefaultMessage("mytopic", "message with external attachment") m.ID = "m3" - m.SID = "m3" + m.SequenceID = "m3" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "car.jpg", @@ -434,7 +434,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { m = newDefaultMessage("mytopic2", "message with expired attachment") m.ID = "m4" - m.SID = "m4" + m.SequenceID = "m4" m.Expires = time.Now().Add(2 * time.Hour).Unix() m.Attachment = &attachment{ Name: "expired-car.jpg", diff --git a/server/server.go b/server/server.go index edae9f2a..c60154ec 100644 --- a/server/server.go +++ b/server/server.go @@ -917,13 +917,13 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return errHTTPTooManyRequestsLimitMessages.With(t) } - sid, e := s.sidFromPath(r.URL.Path) + sequenceID, e := s.sequenceIDFromPath(r.URL.Path) if e != nil { return e.With(t) } - // Create a delete message: empty body, same SID, deleted flag set + // Create a delete message: empty body, same SequenceID, deleted flag set m := newDefaultMessage(t.ID, deletedMessageBody) - m.SID = sid + m.SequenceID = sequenceID m.Deleted = true m.Sender = v.IP() m.User = v.MaybeUserID() @@ -944,7 +944,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor if err := s.messageCache.AddMessage(m); err != nil { return err } - logvrm(v, r, m).Tag(tagPublish).Debug("Deleted message with SID %s", sid) + logvrm(v, r, m).Tag(tagPublish).Debug("Deleted message with sequence ID %s", sequenceID) s.mu.Lock() s.messages++ s.mu.Unlock() @@ -1009,21 +1009,21 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) { if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) { - pathSID, err := s.sidFromPath(r.URL.Path) + pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path) if err != nil { return false, false, "", "", "", false, err } - m.SID = pathSID + m.SequenceID = pathSequenceID } else { - sid := readParam(r, "x-sequence-id", "sequence-id", "sid") - if sid != "" { - if sidRegex.MatchString(sid) { - m.SID = sid + sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid") + if sequenceID != "" { + if sidRegex.MatchString(sequenceID) { + m.SequenceID = sequenceID } else { return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid } } else { - m.SID = m.ID + m.SequenceID = m.ID } } cache = readBoolParam(r, true, "x-cache", "cache") @@ -1764,8 +1764,8 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { return topics, parts[1], nil } -// sidFromPath returns the SID from a POST path like /mytopic/sidHere -func (s *Server) sidFromPath(path string) (string, *errHTTP) { +// sequenceIDFromPath returns the sequence ID from a POST path like /mytopic/sequenceIdHere +func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { parts := strings.Split(path, "/") if len(parts) != 3 { return "", errHTTPBadRequestSIDInvalid diff --git a/server/server_test.go b/server/server_test.go index c1b78c63..2978947f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -684,7 +684,7 @@ func TestServer_PublishWithSIDInPath(t *testing.T) { response := request(t, s, "POST", "/mytopic/sid", "message", nil) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid", msg.SID) + require.Equal(t, "sid", msg.SequenceID) } func TestServer_PublishWithSIDInHeader(t *testing.T) { @@ -695,7 +695,7 @@ func TestServer_PublishWithSIDInHeader(t *testing.T) { }) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid", msg.SID) + require.Equal(t, "sid", msg.SequenceID) } func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { @@ -706,7 +706,7 @@ func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { }) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SID) // SID in path has priority over SID in header + require.Equal(t, "sid1", msg.SequenceID) // Sequence ID in path has priority over header } func TestServer_PublishWithSIDInQuery(t *testing.T) { @@ -715,7 +715,7 @@ func TestServer_PublishWithSIDInQuery(t *testing.T) { response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SID) + require.Equal(t, "sid1", msg.SequenceID) } func TestServer_PublishWithSIDViaGet(t *testing.T) { @@ -724,7 +724,7 @@ func TestServer_PublishWithSIDViaGet(t *testing.T) { response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil) msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SID) + require.Equal(t, "sid1", msg.SequenceID) } func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { diff --git a/server/types.go b/server/types.go index b68721a3..e9c0fdb8 100644 --- a/server/types.go +++ b/server/types.go @@ -24,11 +24,11 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) - Time int64 `json:"time"` // Unix time in seconds - Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) - Event string `json:"event"` // One of the above + ID string `json:"id"` // Random message ID + SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) + Time int64 `json:"time"` // Unix time in seconds + Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) + Event string `json:"event"` // One of the above Topic string `json:"topic"` Title string `json:"title,omitempty"` Message string `json:"message"` // Allow empty message body @@ -48,12 +48,12 @@ type message struct { func (m *message) Context() log.Context { fields := map[string]any{ - "topic": m.Topic, - "message_id": m.ID, - "message_sid": m.SID, - "message_time": m.Time, - "message_event": m.Event, - "message_body_size": len(m.Message), + "topic": m.Topic, + "message_id": m.ID, + "message_sequence_id": m.SequenceID, + "message_time": m.Time, + "message_event": m.Event, + "message_body_size": len(m.Message), } if m.Sender.IsValid() { fields["message_sender"] = m.Sender.String() @@ -94,23 +94,23 @@ func newAction() *action { // publishMessage is used as input when publishing as JSON type publishMessage struct { - Topic string `json:"topic"` - SID string `json:"sid"` - Title string `json:"title"` - Message string `json:"message"` - Priority int `json:"priority"` - Tags []string `json:"tags"` - Click string `json:"click"` - Icon string `json:"icon"` - Actions []action `json:"actions"` - Attach string `json:"attach"` - Markdown bool `json:"markdown"` - Filename string `json:"filename"` - Email string `json:"email"` - Call string `json:"call"` - Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead) - Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead) - Delay string `json:"delay"` + Topic string `json:"topic"` + SequenceID string `json:"sequence_id"` + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + Tags []string `json:"tags"` + Click string `json:"click"` + Icon string `json:"icon"` + Actions []action `json:"actions"` + Attach string `json:"attach"` + Markdown bool `json:"markdown"` + Filename string `json:"filename"` + Email string `json:"email"` + Call string `json:"call"` + Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead) + Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead) + Delay string `json:"delay"` } // messageEncoder is a function that knows how to encode a message diff --git a/web/public/sw.js b/web/public/sw.js index 3a67da58..38dbc9c1 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -8,7 +8,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; -import { messageWithSID } from "../src/app/utils"; +import { messageWithSequenceId } from "../src/app/utils"; /** * General docs for service workers and PWAs: @@ -27,14 +27,15 @@ const addNotification = async ({ subscriptionId, message }) => { // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too - // Delete existing notification with same SID (if any) - if (message.sid) { - await db.notifications.where({ subscriptionId, sid: message.sid }).delete(); + // Delete existing notification with same sequence ID (if any) + const sequenceId = message.sequence_id || message.id; + if (sequenceId) { + await db.notifications.where({ subscriptionId, sequenceId }).delete(); } // Add notification to database await db.notifications.add({ - ...messageWithSID(message), + ...messageWithSequenceId(message), subscriptionId, new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 5c7d2e2d..aa0e6dba 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -47,22 +47,25 @@ class Poller { // Filter out notifications older than the prune threshold const deleteAfterSeconds = await prefs.deleteAfter(); const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0; - const recentNotifications = pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications; + const recentNotifications = + pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications; // Find the latest notification for each sequence ID - const latestBySid = this.latestNotificationsBySid(recentNotifications); + const latestBySequenceId = this.latestNotificationsBySequenceId(recentNotifications); // Delete all existing notifications for which the latest notification is marked as deleted - const deletedSids = Object.entries(latestBySid) + const deletedSequenceIds = Object.entries(latestBySequenceId) .filter(([, notification]) => notification.deleted) - .map(([sid]) => sid); - if (deletedSids.length > 0) { - console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSids); - await Promise.all(deletedSids.map((sid) => subscriptionManager.deleteNotificationBySid(subscription.id, sid))); + .map(([sequenceId]) => sequenceId); + if (deletedSequenceIds.length > 0) { + console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds); + await Promise.all( + deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId)) + ); } // Add only the latest notification for each non-deleted sequence - const notificationsToAdd = Object.values(latestBySid).filter((n) => !n.deleted); + const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => !n.deleted); if (notificationsToAdd.length > 0) { console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); @@ -82,18 +85,18 @@ class Poller { } /** - * Groups notifications by sid and returns only the latest (highest time) for each sequence. - * Returns an object mapping sid -> latest notification. + * Groups notifications by sequenceId and returns only the latest (highest time) for each sequence. + * Returns an object mapping sequenceId -> latest notification. */ - latestNotificationsBySid(notifications) { - const latestBySid = {}; + latestNotificationsBySequenceId(notifications) { + const latestBySequenceId = {}; notifications.forEach((notification) => { - const sid = notification.sid || notification.id; - if (!(sid in latestBySid) || notification.time >= latestBySid[sid].time) { - latestBySid[sid] = notification; + const sequenceId = notification.sequence_id || notification.id; + if (!(sequenceId in latestBySequenceId) || notification.time >= latestBySequenceId[sequenceId].time) { + latestBySequenceId[sequenceId] = notification; } }); - return latestBySid; + return latestBySequenceId; } } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 4a4c6f54..772b30a7 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -2,7 +2,7 @@ import api from "./Api"; import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; -import { messageWithSID, topicUrl } from "./utils"; +import { messageWithSequenceId, topicUrl } from "./utils"; class SubscriptionManager { constructor(dbImpl) { @@ -15,7 +15,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), })) ); } @@ -83,7 +83,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null + last: null, }; await this.db.subscriptions.put(subscription); @@ -101,7 +101,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation // May be null! + reservation, // May be null! }); return local.id; @@ -183,15 +183,15 @@ class SubscriptionManager { // Add notification to database await this.db.notifications.add({ - ...messageWithSID(notification), + ...messageWithSequenceId(notification), subscriptionId, - new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id + last: notification.id, }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -202,12 +202,12 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { const notificationsWithSubscriptionId = notifications.map((notification) => { - return { ...messageWithSID(notification), subscriptionId }; + return { ...messageWithSequenceId(notification), subscriptionId }; }); const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId + last: lastNotificationId, }); } @@ -228,8 +228,8 @@ class SubscriptionManager { await this.db.notifications.delete(notificationId); } - async deleteNotificationBySid(subscriptionId, sid) { - await this.db.notifications.where({ subscriptionId, sid }).delete(); + async deleteNotificationBySequenceId(subscriptionId, sequenceId) { + await this.db.notifications.where({ subscriptionId, sequenceId }).delete(); } async deleteNotifications(subscriptionId) { @@ -240,8 +240,8 @@ class SubscriptionManager { await this.db.notifications.where({ id: notificationId }).modify({ new: 0 }); } - async markNotificationReadBySid(subscriptionId, sid) { - await this.db.notifications.where({ subscriptionId, sid }).modify({ new: 0 }); + async markNotificationReadBySequenceId(subscriptionId, sequenceId) { + await this.db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); } async markNotificationsRead(subscriptionId) { @@ -250,19 +250,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil + mutedUntil, }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName + displayName, }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation + reservation, }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index 1bda553f..7e3c47e3 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,12 +11,11 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(6).stores({ - // FIXME Should be 3 + db.version(3).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sid]", + notifications: "&id,sequenceId,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sequenceId]", users: "&baseUrl,username", - prefs: "&key", + prefs: "&key" }); return db; diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 6cb8bc37..65b5bd3d 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -62,7 +62,7 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to icon, image, timestamp: message.time * 1000, - tag: message.sid || message.id, // Update notification if there is a sequence ID + tag: message.sequence_id || message.id, // Update notification if there is a sequence ID renotify: true, silent: false, // This is used by the notification onclick event diff --git a/web/src/app/utils.js b/web/src/app/utils.js index d9f851b4..9aeada05 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -103,9 +103,9 @@ export const maybeActionErrors = (notification) => { return actionErrors; }; -export const messageWithSID = (message) => { - if (!message.sid) { - message.sid = message.id; +export const messageWithSequenceId = (message) => { + if (!message.sequenceId) { + message.sequenceId = message.sequence_id || message.id; } return message; }; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 3b171eab..5b50f0a8 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -53,9 +53,10 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() // and FirebaseService::handleMessage(). - // Delete existing notification with same sid, if any - if (notification.sid) { - await subscriptionManager.deleteNotificationBySid(subscriptionId, notification.sid); + // Delete existing notification with same sequenceId, if any + const sequenceId = notification.sequence_id || notification.id; + if (sequenceId) { + await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); } // Add notification to database if (!notification.deleted) { From 66ea25c18b76fe5ba167b052caddcc57fd3219a4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 15:45:50 -0500 Subject: [PATCH 063/548] Add JSON publishing support --- server/server.go | 3 +++ server/server_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/server/server.go b/server/server.go index c60154ec..635258fa 100644 --- a/server/server.go +++ b/server/server.go @@ -2027,6 +2027,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { if m.Firebase != "" { r.Header.Set("X-Firebase", m.Firebase) } + if m.SequenceID != "" { + r.Header.Set("X-Sequence-ID", m.SequenceID) + } return next(w, r, v) } } diff --git a/server/server_test.go b/server/server_test.go index 2978947f..964d6156 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -727,6 +727,18 @@ func TestServer_PublishWithSIDViaGet(t *testing.T) { require.Equal(t, "sid1", msg.SequenceID) } +func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) + + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "my-sequence-123", msg.SequenceID) +} + func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { s := newTestServer(t, newTestConfig(t)) From 5ad3de290400af8db89d0602977ca50bea91fa7e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 20:50:23 -0500 Subject: [PATCH 064/548] Switch to event type --- server/errors.go | 2 +- server/message_cache.go | 39 +++++++++--------- server/server.go | 64 +++++++++++++++++++++++++----- server/types.go | 20 +++++++--- web/public/sw.js | 57 +++++++++++++++++++++++--- web/src/app/Connection.js | 6 ++- web/src/app/Poller.js | 7 +++- web/src/app/SubscriptionManager.js | 23 ++++++----- web/src/app/db.js | 2 +- web/src/app/events.js | 14 +++++++ web/src/components/hooks.js | 20 ++++++---- 11 files changed, 187 insertions(+), 67 deletions(-) create mode 100644 web/src/app/events.js diff --git a/server/errors.go b/server/errors.go index 950d23fc..e8f58d75 100644 --- a/server/errors.go +++ b/server/errors.go @@ -125,7 +125,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} + errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", 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} diff --git a/server/message_cache.go b/server/message_cache.go index c00c67c8..396cd7a2 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -51,7 +51,7 @@ const ( content_type TEXT NOT NULL, encoding TEXT NOT NULL, published INT NOT NULL, - deleted INT NOT NULL + event TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); @@ -69,58 +69,57 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) + INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, event) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id ` - selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` - updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` - updateMessageDeletedQuery = `UPDATE messages SET deleted = 1 WHERE mid = ?` - selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` + selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` + updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` @@ -268,7 +267,7 @@ const ( //13 -> 14 migrate13To14AlterMessagesTableQuery = ` ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message'); CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); ` ) @@ -381,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error { } defer stmt.Close() for _, m := range ms { - if m.Event != messageEvent { + if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageReadEvent { return errUnexpectedMessageType } published := m.Time <= time.Now().Unix() @@ -431,7 +430,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, - m.Deleted, + m.Event, ) if err != nil { return err @@ -720,8 +719,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 var priority int - var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string - var deleted bool + var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding, event string err := rows.Scan( &id, &sequenceID, @@ -744,7 +742,7 @@ func readMessage(rows *sql.Rows) (*message, error) { &user, &contentType, &encoding, - &deleted, + &event, ) if err != nil { return nil, err @@ -782,7 +780,7 @@ func readMessage(rows *sql.Rows) (*message, error) { SequenceID: sequenceID, Time: timestamp, Expires: expires, - Event: messageEvent, + Event: event, Topic: topic, Message: msg, Title: title, @@ -796,7 +794,6 @@ func readMessage(rows *sql.Rows) (*message, error) { User: user, ContentType: contentType, Encoding: encoding, - Deleted: deleted, }, nil } diff --git a/server/server.go b/server/server.go index 635258fa..8aff7f7e 100644 --- a/server/server.go +++ b/server/server.go @@ -80,8 +80,9 @@ var ( wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) - sidRegex = topicRegex updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) + markReadPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/read$`) + sequenceIDRegex = topicRegex webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" @@ -140,7 +141,6 @@ const ( firebaseControlTopic = "~control" // See Android if changed firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now) emptyMessageBody = "triggered" // Used when a message body is empty - deletedMessageBody = "deleted" // Used when a message is deleted newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages @@ -550,6 +550,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) + } else if r.Method == http.MethodPut && markReadPathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleMarkRead))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { @@ -921,10 +923,8 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor if e != nil { return e.With(t) } - // Create a delete message: empty body, same SequenceID, deleted flag set - m := newDefaultMessage(t.ID, deletedMessageBody) - m.SequenceID = sequenceID - m.Deleted = true + // Create a delete message with event type message_delete + m := newActionMessage(messageDeleteEvent, t.ID, sequenceID) m.Sender = v.IP() m.User = v.MaybeUserID() m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() @@ -951,6 +951,50 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor return s.writeJSON(w, m) } +func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visitor) error { + t, err := fromContext[*topic](r, contextTopic) + if err != nil { + return err + } + vrate, err := fromContext[*visitor](r, contextRateVisitor) + if err != nil { + return err + } + if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { + return errHTTPTooManyRequestsLimitMessages.With(t) + } + sequenceID, e := s.sequenceIDFromPath(r.URL.Path) + if e != nil { + return e.With(t) + } + // Create a read message with event type message_read + m := newActionMessage(messageReadEvent, t.ID, sequenceID) + 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 { + 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) + } + // Add to message cache + if err := s.messageCache.AddMessage(m); err != nil { + return err + } + logvrm(v, r, m).Tag(tagPublish).Debug("Marked message as read with sequence ID %s", sequenceID) + s.mu.Lock() + s.messages++ + s.mu.Unlock() + return s.writeJSON(w, m) +} + func (s *Server) sendToFirebase(v *visitor, m *message) { logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") if err := s.firebaseClient.Send(v, m); err != nil { @@ -1017,10 +1061,10 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } else { sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid") if sequenceID != "" { - if sidRegex.MatchString(sequenceID) { + if sequenceIDRegex.MatchString(sequenceID) { m.SequenceID = sequenceID } else { - return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid + return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid } } else { m.SequenceID = m.ID @@ -1767,8 +1811,8 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { // sequenceIDFromPath returns the sequence ID from a POST path like /mytopic/sequenceIdHere func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { parts := strings.Split(path, "/") - if len(parts) != 3 { - return "", errHTTPBadRequestSIDInvalid + if len(parts) < 3 { + return "", errHTTPBadRequestSequenceIDInvalid } return parts[2], nil } diff --git a/server/types.go b/server/types.go index e9c0fdb8..5f9917d1 100644 --- a/server/types.go +++ b/server/types.go @@ -12,10 +12,12 @@ import ( // List of possible events const ( - openEvent = "open" - keepaliveEvent = "keepalive" - messageEvent = "message" - pollRequestEvent = "poll_request" + openEvent = "open" + keepaliveEvent = "keepalive" + messageEvent = "message" + messageDeleteEvent = "message_delete" + messageReadEvent = "message_read" + pollRequestEvent = "poll_request" ) const ( @@ -41,7 +43,6 @@ type message struct { PollID string `json:"poll_id,omitempty"` ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes - Deleted bool `json:"deleted,omitempty"` // True if message is marked as deleted Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting User string `json:"-"` // UserID of the uploader, used to associated attachments } @@ -149,6 +150,13 @@ func newPollRequestMessage(topic, pollID string) *message { return m } +// newActionMessage creates a new action message (message_delete or message_read) +func newActionMessage(event, topic, sequenceID string) *message { + m := newMessage(event, topic, "") + m.SequenceID = sequenceID + return m +} + func validMessageID(s string) bool { return util.ValidRandomString(s, messageIDLength) } @@ -227,7 +235,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { } func (q *queryFilter) Pass(msg *message) bool { - if msg.Event != messageEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { return true // filters only apply to messages } else if q.ID != "" && msg.ID != q.ID { return false diff --git a/web/public/sw.js b/web/public/sw.js index 38dbc9c1..7d6441e3 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -9,6 +9,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; import { messageWithSequenceId } from "../src/app/utils"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -62,11 +63,6 @@ const handlePushMessage = async (data) => { // Add notification to database await addNotification({ subscriptionId, message }); - // Don't show a notification for deleted messages - if (message.deleted) { - return; - } - // Broadcast the message to potentially play a sound broadcastChannel.postMessage(message); @@ -80,6 +76,51 @@ const handlePushMessage = async (data) => { ); }; +/** + * Handle a message_delete event: delete the notification from the database. + */ +const handlePushMessageDelete = async (data) => { + const { subscription_id: subscriptionId, message } = data; + const db = await dbAsync(); + + // Delete notification with the same sequence_id + const sequenceId = message.sequence_id; + if (sequenceId) { + console.log("[ServiceWorker] Deleting notification with sequenceId", { subscriptionId, sequenceId }); + await db.notifications.where({ subscriptionId, sequenceId }).delete(); + } + + // Update subscription last message id (for ?since=... queries) + await db.subscriptions.update(subscriptionId, { + last: message.id, + }); +}; + +/** + * Handle a message_read event: mark the notification as read. + */ +const handlePushMessageRead = async (data) => { + const { subscription_id: subscriptionId, message } = data; + const db = await dbAsync(); + + // Mark notification as read (set new = 0) + const sequenceId = message.sequence_id; + if (sequenceId) { + console.log("[ServiceWorker] Marking notification as read", { subscriptionId, sequenceId }); + await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); + } + + // Update subscription last message id (for ?since=... queries) + await db.subscriptions.update(subscriptionId, { + last: message.id, + }); + + // Update badge count + const badgeCount = await db.notifications.where({ new: 1 }).count(); + console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); + self.navigator.setAppBadge?.(badgeCount); +}; + /** * Handle a received web push subscription expiring. */ @@ -114,8 +155,12 @@ const handlePushUnknown = async (data) => { * @param {object} data see server/types.go, type webPushPayload */ const handlePush = async (data) => { - if (data.event === "message") { + if (data.event === EVENT_MESSAGE) { await handlePushMessage(data); + } else if (data.event === EVENT_MESSAGE_DELETE) { + await handlePushMessageDelete(data); + } else if (data.event === EVENT_MESSAGE_READ) { + await handlePushMessageRead(data); } else if (data.event === "subscription_expiring") { await handlePushSubscriptionExpiring(data); } else { diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 5358cdde..06043acc 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils"; +import { EVENT_OPEN, isNotificationEvent } from "./events"; const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; @@ -48,10 +49,11 @@ class Connection { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); try { const data = JSON.parse(event.data); - if (data.event === "open") { + if (data.event === EVENT_OPEN) { return; } - const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data; + // Accept message, message_delete, and message_read events + const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data; if (!relevantAndValid) { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); return; diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index aa0e6dba..56415b76 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -1,6 +1,7 @@ import api from "./Api"; import prefs from "./Prefs"; import subscriptionManager from "./SubscriptionManager"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from "./events"; const delayMillis = 2000; // 2 seconds const intervalMillis = 300000; // 5 minutes @@ -55,7 +56,7 @@ class Poller { // Delete all existing notifications for which the latest notification is marked as deleted const deletedSequenceIds = Object.entries(latestBySequenceId) - .filter(([, notification]) => notification.deleted) + .filter(([, notification]) => notification.event === EVENT_MESSAGE_DELETE) .map(([sequenceId]) => sequenceId); if (deletedSequenceIds.length > 0) { console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds); @@ -65,7 +66,9 @@ class Poller { } // Add only the latest notification for each non-deleted sequence - const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => !n.deleted); + const notificationsToAdd = Object + .values(latestBySequenceId) + .filter(n => n.event === EVENT_MESSAGE); if (notificationsToAdd.length > 0) { console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 772b30a7..2eecde28 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -3,6 +3,7 @@ import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; import { messageWithSequenceId, topicUrl } from "./utils"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "./events"; class SubscriptionManager { constructor(dbImpl) { @@ -15,7 +16,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() })) ); } @@ -48,7 +49,7 @@ class SubscriptionManager { } async notify(subscriptionId, notification) { - if (notification.deleted) { + if (notification.event !== EVENT_MESSAGE) { return; } const subscription = await this.get(subscriptionId); @@ -83,7 +84,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null, + last: null }; await this.db.subscriptions.put(subscription); @@ -101,7 +102,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation, // May be null! + reservation // May be null! }); return local.id; @@ -174,7 +175,7 @@ class SubscriptionManager { /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); - if (exists || notification.deleted) { + if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_READ) { return false; } try { @@ -185,13 +186,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSequenceId(notification), subscriptionId, - new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id, + last: notification.id }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -207,7 +208,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId, + last: lastNotificationId }); } @@ -250,19 +251,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil, + mutedUntil }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName, + displayName }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation, + reservation }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index 7e3c47e3..cb65c0b6 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -13,7 +13,7 @@ const createDatabase = (username) => { db.version(3).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sequenceId,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sequenceId]", + notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]", users: "&baseUrl,username", prefs: "&key" }); diff --git a/web/src/app/events.js b/web/src/app/events.js new file mode 100644 index 00000000..55dc262c --- /dev/null +++ b/web/src/app/events.js @@ -0,0 +1,14 @@ +// Event types for ntfy messages +// These correspond to the server event types in server/types.go + +export const EVENT_OPEN = "open"; +export const EVENT_KEEPALIVE = "keepalive"; +export const EVENT_MESSAGE = "message"; +export const EVENT_MESSAGE_DELETE = "message_delete"; +export const EVENT_MESSAGE_READ = "message_read"; +export const EVENT_POLL_REQUEST = "poll_request"; + +// Check if an event is a notification event (message, delete, or read) +export const isNotificationEvent = (event) => + event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_READ; + diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 5b50f0a8..5e271b35 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -12,6 +12,7 @@ import accountApi from "../app/AccountApi"; import { UnauthorizedError } from "../app/errors"; import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; +import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../app/events"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -53,13 +54,18 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() // and FirebaseService::handleMessage(). - // Delete existing notification with same sequenceId, if any - const sequenceId = notification.sequence_id || notification.id; - if (sequenceId) { - await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); - } - // Add notification to database - if (!notification.deleted) { + if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { + // Handle delete: remove notification from database + await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); + } else if (notification.event === EVENT_MESSAGE_READ && notification.sequence_id) { + // Handle read: mark notification as read + await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); + } else { + // Regular message: delete existing and add new + const sequenceId = notification.sequence_id || notification.id; + if (sequenceId) { + await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); + } const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { await subscriptionManager.notify(subscriptionId, notification); From 37d71051de90ee02c978ff17e26fe7da7fb49e0e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 8 Jan 2026 20:57:55 -0500 Subject: [PATCH 065/548] Refine --- server/server.go | 75 +++++++++++++--------------------------- server/server_webpush.go | 2 +- server/types.go | 11 ++++++ 3 files changed, 36 insertions(+), 52 deletions(-) diff --git a/server/server.go b/server/server.go index 8aff7f7e..e111434d 100644 --- a/server/server.go +++ b/server/server.go @@ -879,7 +879,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } minc(metricMessagesPublishedSuccess) - return s.writeJSON(w, m) + return s.writeJSON(w, m.forJSON()) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -908,50 +908,14 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - t, err := fromContext[*topic](r, contextTopic) - if err != nil { - return err - } - vrate, err := fromContext[*visitor](r, contextRateVisitor) - if err != nil { - return err - } - if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { - return errHTTPTooManyRequestsLimitMessages.With(t) - } - sequenceID, e := s.sequenceIDFromPath(r.URL.Path) - if e != nil { - return e.With(t) - } - // Create a delete message with event type message_delete - m := newActionMessage(messageDeleteEvent, t.ID, sequenceID) - 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 { - 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) - } - // Add to message cache - if err := s.messageCache.AddMessage(m); err != nil { - return err - } - logvrm(v, r, m).Tag(tagPublish).Debug("Deleted message with sequence ID %s", sequenceID) - s.mu.Lock() - s.messages++ - s.mu.Unlock() - return s.writeJSON(w, m) + return s.handleActionMessage(w, r, v, messageDeleteEvent, s.sequenceIDFromPath) } func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visitor) error { + return s.handleActionMessage(w, r, v, messageReadEvent, s.sequenceIDFromMarkReadPath) +} + +func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string, extractSequenceID func(string) (string, *errHTTP)) error { t, err := fromContext[*topic](r, contextTopic) if err != nil { return err @@ -963,12 +927,12 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visit if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return errHTTPTooManyRequestsLimitMessages.With(t) } - sequenceID, e := s.sequenceIDFromPath(r.URL.Path) + sequenceID, e := extractSequenceID(r.URL.Path) if e != nil { return e.With(t) } - // Create a read message with event type message_read - m := newActionMessage(messageReadEvent, t.ID, sequenceID) + // Create an action message with the given event type + m := newActionMessage(event, t.ID, sequenceID) m.Sender = v.IP() m.User = v.MaybeUserID() m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() @@ -988,11 +952,11 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visit if err := s.messageCache.AddMessage(m); err != nil { return err } - logvrm(v, r, m).Tag(tagPublish).Debug("Marked message as read with sequence ID %s", sequenceID) + logvrm(v, r, m).Tag(tagPublish).Debug("Published %s for sequence ID %s", event, sequenceID) s.mu.Lock() s.messages++ s.mu.Unlock() - return s.writeJSON(w, m) + return s.writeJSON(w, m.forJSON()) } func (s *Server) sendToFirebase(v *visitor, m *message) { @@ -1384,7 +1348,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } return buf.String(), nil @@ -1395,10 +1359,10 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v * func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } - if msg.Event != messageEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this! } return fmt.Sprintf("data: %s\n", buf.String()), nil @@ -1808,7 +1772,7 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { return topics, parts[1], nil } -// sequenceIDFromPath returns the sequence ID from a POST path like /mytopic/sequenceIdHere +// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { parts := strings.Split(path, "/") if len(parts) < 3 { @@ -1817,6 +1781,15 @@ func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { return parts[2], nil } +// sequenceIDFromMarkReadPath returns the sequence ID from a path like /mytopic/sequenceIdHere/read +func (s *Server) sequenceIDFromMarkReadPath(path string) (string, *errHTTP) { + parts := strings.Split(path, "/") + if len(parts) < 4 || parts[3] != "read" { + return "", errHTTPBadRequestSequenceIDInvalid + } + return parts[2], nil +} + // topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { s.mu.Lock() diff --git a/server/server_webpush.go b/server/server_webpush.go index 526e06f2..d3f09bd9 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -89,7 +89,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { return } log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions)) - payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m)) + payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.forJSON())) if err != nil { log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload") return diff --git a/server/types.go b/server/types.go index 5f9917d1..ec682133 100644 --- a/server/types.go +++ b/server/types.go @@ -65,6 +65,17 @@ func (m *message) Context() log.Context { return fields } +// forJSON returns a copy of the message suitable for JSON output. +// It clears the SequenceID if it equals the ID to reduce redundancy. +func (m *message) forJSON() *message { + if m.SequenceID == m.ID { + clone := *m + clone.SequenceID = "" + return &clone + } + return m +} + type attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` From 13768d42896d9583f540b4e3c86bd2fc7743e1f0 Mon Sep 17 00:00:00 2001 From: "Kristijan \\\"Fremen\\\" Velkovski" Date: Fri, 9 Jan 2026 04:25:51 +0100 Subject: [PATCH 066/548] Translated using Weblate (Macedonian) Currently translated at 16.4% (67 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/ --- web/public/static/langs/mk.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/mk.json b/web/public/static/langs/mk.json index 56417357..aeaff7cb 100644 --- a/web/public/static/langs/mk.json +++ b/web/public/static/langs/mk.json @@ -58,5 +58,12 @@ "nav_upgrade_banner_label": "Надградете на ntfy Pro", "nav_upgrade_banner_description": "Резервирајте теми, повеќе пораки и е-пораки и поголеми прилози", "alert_notification_permission_required_title": "Известувањата се исклучени", - "alert_notification_permission_required_description": "Дајте му дозвола на вашиот прелистувач да прикажува известувања" + "alert_notification_permission_required_description": "Дајте му дозвола на вашиот прелистувач да прикажува известувања", + "nav_button_muted": "Известувањата се загушени", + "alert_not_supported_title": "Известувањата не се поддржани", + "alert_not_supported_description": "Известувањата не се поддржани во вашиот прелистувач", + "alert_not_supported_context_description": "Известувањата се поддржани само преку HTTPS. Ова е ограничување на Notifications API .", + "notifications_list": "Список на известувања", + "notifications_list_item": "Известување", + "notifications_mark_read": "Означи како прочитано" } From 7bc8edf7c3a6736dcf17d53bc55b2a306cf42920 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 10 Jan 2026 20:47:59 -0500 Subject: [PATCH 067/548] Bump, release notes --- docs/releases.md | 56 ++++---- go.mod | 26 ++-- go.sum | 27 ++++ web/package-lock.json | 293 ++++++++++++++++++++++++------------------ 4 files changed, 242 insertions(+), 160 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index afcd849a..8df4d1b4 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -4,14 +4,41 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Current stable releases -| Component | Version | Release date | -|------------------------------------------|---------|--------------| -| ntfy server | v2.15.0 | Nov 16, 2025 | -| ntfy Android app (_is being rolled out_) | v1.20.0 | Dec 28, 2025 | -| ntfy iOS app | v1.3 | Nov 26, 2023 | +| Component | Version | Release date | +|------------------|---------|--------------| +| ntfy server | v2.15.0 | Nov 16, 2025 | +| ntfy Android app | v1.21.1 | Jan 6, 2025 | +| ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy Android app v1.21.1 +Released January 6, 2026 + +This is the first feature release in a long time. After all the SDK updates, fixes to comply with the Google Play policies +and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style), +support for passing headers to your proxy, an in-app language switcher, and more. + +If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc). +If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us. + +**Features:** + +* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144)) +* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13)) +* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g)) +* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting) +* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting) +* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski)) + +**Bug fixes + maintenance:** + +* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco)) +* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel)) +* Fix crash in user add dialog (onAddUser) +* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520)) +* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer)) + ## ntfy Android app v1.20.0 Released December 28, 2025 @@ -1572,21 +1599,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy Android app v1.21.1-rc1 (IN TESTING) - -**Features:** - -* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144)) -* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13)) -* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g)) -* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting) -* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting) -* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski)) - -**Bug fixes + maintenance:** - -* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco)) -* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel)) -* Fix crash in user add dialog (onAddUser) -* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520)) -* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer)) +Nothing to see here yet. \ No newline at end of file diff --git a/go.mod b/go.mod index e6d91101..aa88fd68 100644 --- a/go.mod +++ b/go.mod @@ -6,22 +6,22 @@ toolchain go1.24.5 require ( cloud.google.com/go/firestore v1.20.0 // indirect - cloud.google.com/go/storage v1.58.0 // indirect + cloud.google.com/go/storage v1.59.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.18.0 github.com/gabriel-vasile/mimetype v1.4.12 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.32 + github.com/mattn/go-sqlite3 v1.14.33 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.46.0 golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 - golang.org/x/term v0.38.0 + golang.org/x/term v0.39.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.258.0 + google.golang.org/api v0.259.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -35,7 +35,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/text v0.32.0 + golang.org/x/text v0.33.0 ) require ( @@ -45,7 +45,7 @@ require ( 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.5.3 // indirect - cloud.google.com/go/longrunning v0.7.0 // indirect + cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect @@ -69,14 +69,14 @@ require ( 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.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.9 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 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.67.4 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect @@ -93,12 +93,12 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect + golang.org/x/sys v0.40.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/grpc v1.77.0 // indirect + google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index babf7049..72888b0f 100644 --- a/go.sum +++ b/go.sum @@ -16,10 +16,14 @@ cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3Q cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo= cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8= +cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw= @@ -98,6 +102,8 @@ 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.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/enterprise-certificate-proxy v0.3.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU= +github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -114,6 +120,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= @@ -133,6 +141,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -227,6 +237,8 @@ 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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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= @@ -238,6 +250,8 @@ 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.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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= @@ -251,6 +265,8 @@ 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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -265,16 +281,27 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc= google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww= +google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= +google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 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-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM= google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b h1:kqShdsddZrS6q+DGBCA73CzHsKDu5vW4qw78tFnbVvY= +google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:gw1DtiPCt5uh/HV9STVEeaO00S5ATsJiJ2LsZV8lcDI= google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= 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= diff --git a/web/package-lock.json b/web/package-lock.json index 789b95f7..a1bba16c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2198,9 +2198,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2711,9 +2711,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", - "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -2798,9 +2798,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "cpu": [ "arm" ], @@ -2812,9 +2812,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "cpu": [ "arm64" ], @@ -2826,9 +2826,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "cpu": [ "arm64" ], @@ -2840,9 +2840,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "cpu": [ "x64" ], @@ -2854,9 +2854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", "cpu": [ "arm64" ], @@ -2868,9 +2868,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", "cpu": [ "x64" ], @@ -2882,9 +2882,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "cpu": [ "arm" ], @@ -2896,9 +2896,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "cpu": [ "arm" ], @@ -2910,9 +2910,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "cpu": [ "arm64" ], @@ -2924,9 +2924,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "cpu": [ "arm64" ], @@ -2938,9 +2938,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", "cpu": [ "loong64" ], @@ -2952,9 +2966,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "cpu": [ "ppc64" ], @@ -2966,9 +2994,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", "cpu": [ "riscv64" ], @@ -2980,9 +3008,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "cpu": [ "riscv64" ], @@ -2994,9 +3022,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "cpu": [ "s390x" ], @@ -3008,9 +3036,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "cpu": [ "x64" ], @@ -3022,9 +3050,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "cpu": [ "x64" ], @@ -3035,10 +3063,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "cpu": [ "arm64" ], @@ -3050,9 +3092,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "cpu": [ "arm64" ], @@ -3064,9 +3106,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "cpu": [ "ia32" ], @@ -3078,9 +3120,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "cpu": [ "x64" ], @@ -3092,9 +3134,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "cpu": [ "x64" ], @@ -3206,9 +3248,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", "peer": true, "dependencies": { @@ -3566,9 +3608,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", - "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3660,9 +3702,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3781,9 +3823,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", "dev": true, "funding": [ { @@ -4872,9 +4914,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4969,9 +5011,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -7329,12 +7371,12 @@ } }, "node_modules/react-router": { - "version": "6.30.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", - "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.1" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -7344,13 +7386,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", - "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.1", - "react-router": "6.30.2" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -7586,9 +7628,9 @@ } }, "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", "dependencies": { @@ -7602,28 +7644,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" } }, From 090cd720c12d7749c212ceace312397246ea64df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilio=20Cant=C3=BA?= Date: Sat, 10 Jan 2026 22:03:41 -0500 Subject: [PATCH 068/548] fix typos --- docs/develop.md | 2 +- docs/publish/template-functions.md | 2 +- docs/subscribe/phone.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/develop.md b/docs/develop.md index 4ddff5ec..ecf35e1d 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -441,6 +441,6 @@ To have instant notifications/better notification delivery when using firebase, 1. In XCode, find the NTFY app target. **Not** the NSE app target. 1. Find the Asset/ folder in the project navigator 1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be - found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist" + found in the "Project settings" > "General" > "Your apps" with a button labeled "GoogleService-Info.plist" After that, you should be all set! diff --git a/docs/publish/template-functions.md b/docs/publish/template-functions.md index 79848080..53026627 100644 --- a/docs/publish/template-functions.md +++ b/docs/publish/template-functions.md @@ -1174,7 +1174,7 @@ keys $myDict | sortAlpha ``` When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq` -function along with `sortAlpha` to get a unqiue, sorted list of keys. +function along with `sortAlpha` to get a unique, sorted list of keys. ``` keys $myDict $myOtherDict | uniq | sortAlpha diff --git a/docs/subscribe/phone.md b/docs/subscribe/phone.md index 3015be88..602a0d45 100644 --- a/docs/subscribe/phone.md +++ b/docs/subscribe/phone.md @@ -100,7 +100,7 @@ The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google. notifications. Firebase is overall pretty bad at delivering messages in time, but on Android, most apps are stuck with it. The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app. -It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor. +It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor. ## Share to topic _Supported on:_ :material-android: From a7603d1dfb88e00441e5eb306f6c28b7c0d7a326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=8E=8B=E5=8F=AB=E6=88=91=E6=9D=A5=E5=B7=A1?= =?UTF-8?q?=E5=B1=B1?= Date: Sun, 11 Jan 2026 01:48:53 +0100 Subject: [PATCH 069/548] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/ --- web/public/static/langs/zh_Hans.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json index d421bc35..fbbc1a2f 100644 --- a/web/public/static/langs/zh_Hans.json +++ b/web/public/static/langs/zh_Hans.json @@ -182,7 +182,7 @@ "subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts", "notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。", "publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制", - "publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警", + "publish_dialog_title_placeholder": "通知标题,如磁盘空间告警", "publish_dialog_email_label": "电子邮件", "publish_dialog_button_send": "发送", "publish_dialog_checkbox_markdown": "格式化为 Markdown", @@ -206,7 +206,7 @@ "publish_dialog_tags_placeholder": "英文逗号分隔的标签列表,例如 warning, srv1-backup", "publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅文档。", "subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。", - "publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)", + "publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}} 或 {{naturalLanguage}} (仅限英语)", "account_usage_basis_ip_description": "此账户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。", "account_usage_cannot_create_portal_session": "无法打开计费门户", "account_delete_title": "删除账户", From 88016a2549ee7c855fd036ce438333d59bbe3df8 Mon Sep 17 00:00:00 2001 From: Shoshin Akamine Date: Sun, 11 Jan 2026 04:31:27 +0100 Subject: [PATCH 070/548] Translated using Weblate (Japanese) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/ --- web/public/static/langs/ja.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json index ebd76b54..1f672b2c 100644 --- a/web/public/static/langs/ja.json +++ b/web/public/static/langs/ja.json @@ -14,7 +14,7 @@ "publish_dialog_title_no_topic": "通知を送信", "publish_dialog_progress_uploading": "アップロード中…", "publish_dialog_progress_uploading_detail": "アップロード中 {{loaded}}/{{total}} ({{percent}}%) …", - "publish_dialog_message_published": "通知を送信しました", + "publish_dialog_message_published": "通知送信済み", "publish_dialog_title_label": "タイトル", "publish_dialog_filename_label": "ファイル名", "subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。", @@ -69,10 +69,10 @@ "publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}", "publish_dialog_priority_high": "優先度 高", "publish_dialog_topic_placeholder": "トピック名の例 phil_alerts", - "publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告", + "publish_dialog_title_placeholder": "通知タイトル、例: ディスクスペース警告", "publish_dialog_message_placeholder": "メッセージ本文を入力してください", "publish_dialog_tags_label": "タグ", - "publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください 例: warning, srv1-backup", + "publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください、例: warning, srv1-backup", "publish_dialog_topic_label": "トピック名", "publish_dialog_delay_label": "遅延", "publish_dialog_click_placeholder": "通知をクリックしたときに開くURL", From f581af6d27765ae8d6fc3bb982e326947cb1f6dd Mon Sep 17 00:00:00 2001 From: "Kristijan \\\"Fremen\\\" Velkovski" Date: Sun, 11 Jan 2026 02:41:56 +0100 Subject: [PATCH 071/548] Translated using Weblate (Macedonian) Currently translated at 23.0% (94 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/ --- web/public/static/langs/mk.json | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/mk.json b/web/public/static/langs/mk.json index aeaff7cb..bb19fe1a 100644 --- a/web/public/static/langs/mk.json +++ b/web/public/static/langs/mk.json @@ -65,5 +65,32 @@ "alert_not_supported_context_description": "Известувањата се поддржани само преку HTTPS. Ова е ограничување на Notifications API .", "notifications_list": "Список на известувања", "notifications_list_item": "Известување", - "notifications_mark_read": "Означи како прочитано" + "notifications_mark_read": "Означи како прочитано", + "publish_dialog_attached_file_filename_placeholder": "Име на фајл за прилог", + "notifications_attachment_file_app": "Фајл со апликација за Android", + "notifications_attachment_file_document": "друг документ", + "alert_notification_permission_required_button": "Дајте дозвола сега", + "alert_notification_permission_denied_title": "Известувањата се блокирани", + "alert_notification_permission_denied_description": "Ве молиме повторно овозможете ги во вашиот пребарувач", + "alert_notification_ios_install_required_title": "Потребна е инсталација на iOS", + "alert_notification_ios_install_required_description": "Кликнете на иконата Сподели и Додај на почетниот екран за да овозможите известувања на iOS", + "notifications_delete": "Избриши", + "notifications_copied_to_clipboard": "Копирано во таблата со исечоци", + "notifications_tags": "Ознаки", + "notifications_priority_x": "Приоритет {{приоритет}}", + "notifications_new_indicator": "Ново известување", + "notifications_attachment_image": "Слика од прилог", + "notifications_attachment_copy_url_title": "Копирај URL-адресата на прилогот во таблата со исечоци", + "notifications_attachment_open_title": "Оди на {{url}}", + "notifications_attachment_open_button": "Отвори го прилогот", + "notifications_attachment_link_expires": "линкот истекува {{date}}", + "notifications_attachment_link_expired": "линкот за преземање е истечен", + "notifications_attachment_file_image": "слика фајл", + "notifications_attachment_file_video": "видео фајл", + "notifications_attachment_file_audio": "аудио фајл", + "notifications_click_copy_url_button": "Копирај линк", + "notifications_click_open_button": "Отвори линк", + "notifications_actions_open_url_title": "Оди на {{url}}", + "notifications_actions_not_supported": "Дејството не е поддржано во веб-апликацијата", + "notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}" } From 8960459520b87b0bd3bece181388021ae2c9a599 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 11 Jan 2026 09:26:44 -0500 Subject: [PATCH 072/548] Changelog --- docs/releases.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 8df4d1b4..ed65d8a4 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1599,4 +1599,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -Nothing to see here yet. \ No newline at end of file +### ntfy Android app v1.22.x (UNRELEASED) + +**Features:** + +* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149) + +**Bug fixes + maintenance:** + +* Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting) +* Fix crash in sharing dialog (thanks to @rogeliodh) +* Fix crash when exiting multi-delete in detail view +* Fix potential crashes with icon downloader and backuper From c0a5a1fb3530c711ccc972f066951ece7928907c Mon Sep 17 00:00:00 2001 From: cyberboh Date: Mon, 12 Jan 2026 13:28:12 +0100 Subject: [PATCH 073/548] Translated using Weblate (Indonesian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/ --- web/public/static/langs/id.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index c63d9a12..39c6d1af 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -50,10 +50,10 @@ "publish_dialog_progress_uploading": "Mengunggah …", "notifications_more_details": "Untuk informasi lanjut, lihat situs web atau dokumentasi.", "publish_dialog_progress_uploading_detail": "Mengunggah {{loaded}}/{{total}} ({{percent}}%) …", - "publish_dialog_message_published": "Notifikasi dipublikasi", + "publish_dialog_message_published": "Notifikasi dipublikasikan", "notifications_loading": "Memuat notifikasi …", "publish_dialog_base_url_label": "URL Layanan", - "publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk", + "publish_dialog_title_placeholder": "Judul notifikasi, contoh: Peringatan ruang penyimpanan disk", "publish_dialog_tags_label": "Tanda", "publish_dialog_priority_label": "Prioritas", "publish_dialog_base_url_placeholder": "URL Layanan, mis. https://contoh.com", @@ -73,10 +73,10 @@ "publish_dialog_topic_label": "Nama topik", "publish_dialog_message_placeholder": "Tulis pesan di sini", "publish_dialog_click_label": "Klik URL", - "publish_dialog_tags_placeholder": "Daftar label yang dipisah dengan tanda koma, contoh: peringatan, cadangan-srv1", + "publish_dialog_tags_placeholder": "Daftar label yang dipisahkan koma, contoh: peringatan, cadangan-srv1", "publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik", "publish_dialog_email_label": "Email", - "publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, mis. andi@contoh.com", + "publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, contoh: phil@example.com", "publish_dialog_attach_label": "URL Lampiran", "publish_dialog_filename_label": "Nama File", "publish_dialog_filename_placeholder": "Nama file lampiran", From a3c16d81f88109196b1ffee9af71fe228c676490 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 13 Jan 2026 16:31:13 -0500 Subject: [PATCH 074/548] Rename to clear --- go.sum | 39 +++++++++++++++++++++++++++--- server/message_cache.go | 8 +++--- server/server.go | 27 +++++++-------------- server/types.go | 8 +++--- web/public/sw.js | 10 ++++---- web/src/app/Connection.js | 2 +- web/src/app/Poller.js | 4 +-- web/src/app/SubscriptionManager.js | 22 ++++++++--------- web/src/app/db.js | 2 +- web/src/app/events.js | 6 ++--- web/src/components/hooks.js | 4 +-- 11 files changed, 76 insertions(+), 56 deletions(-) diff --git a/go.sum b/go.sum index 0915f1cb..241b5556 100644 --- a/go.sum +++ b/go.sum @@ -6,20 +6,22 @@ cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= 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 v1.53.0 h1:dILGanjePNsYfZVYYv6K0d4+IPnKX1gn84Fk8jDPNvs= -cloud.google.com/go/compute v1.53.0/go.mod h1:zdogTa7daHhEtEX92+S5IARtQmi/RNVPUfoI8Jhl8Do= 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.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo= cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8= cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw= firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= @@ -30,6 +32,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= @@ -47,14 +51,19 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/Buvy github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI= github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -79,6 +88,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= 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= @@ -91,6 +104,14 @@ 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= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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/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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -104,6 +125,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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= @@ -113,13 +135,17 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY= github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= @@ -138,6 +164,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGN go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= @@ -146,6 +174,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -231,9 +261,10 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 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-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4= @@ -249,6 +280,8 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/message_cache.go b/server/message_cache.go index 396cd7a2..ec1a395e 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -117,9 +117,9 @@ const ( WHERE time <= ? AND published = 0 ORDER BY time, id ` - selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` - updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` - selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` + selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` + updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` @@ -380,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error { } defer stmt.Close() for _, m := range ms { - if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageReadEvent { + if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent { return errUnexpectedMessageType } published := m.Time <= time.Now().Unix() diff --git a/server/server.go b/server/server.go index e111434d..7ef43275 100644 --- a/server/server.go +++ b/server/server.go @@ -81,7 +81,7 @@ var ( authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) - markReadPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/read$`) + clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) sequenceIDRegex = topicRegex webConfigPath = "/config.js" @@ -550,8 +550,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) - } else if r.Method == http.MethodPut && markReadPathRegex.MatchString(r.URL.Path) { - return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleMarkRead))(w, r, v) + } else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { @@ -908,14 +908,14 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - return s.handleActionMessage(w, r, v, messageDeleteEvent, s.sequenceIDFromPath) + return s.handleActionMessage(w, r, v, messageDeleteEvent) } -func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visitor) error { - return s.handleActionMessage(w, r, v, messageReadEvent, s.sequenceIDFromMarkReadPath) +func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error { + return s.handleActionMessage(w, r, v, messageClearEvent) } -func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string, extractSequenceID func(string) (string, *errHTTP)) error { +func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error { t, err := fromContext[*topic](r, contextTopic) if err != nil { return err @@ -927,7 +927,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return errHTTPTooManyRequestsLimitMessages.With(t) } - sequenceID, e := extractSequenceID(r.URL.Path) + sequenceID, e := s.sequenceIDFromPath(r.URL.Path) if e != nil { return e.With(t) } @@ -1362,7 +1362,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } - if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent { return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this! } return fmt.Sprintf("data: %s\n", buf.String()), nil @@ -1781,15 +1781,6 @@ func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { return parts[2], nil } -// sequenceIDFromMarkReadPath returns the sequence ID from a path like /mytopic/sequenceIdHere/read -func (s *Server) sequenceIDFromMarkReadPath(path string) (string, *errHTTP) { - parts := strings.Split(path, "/") - if len(parts) < 4 || parts[3] != "read" { - return "", errHTTPBadRequestSequenceIDInvalid - } - return parts[2], nil -} - // topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { s.mu.Lock() diff --git a/server/types.go b/server/types.go index ec682133..6464222f 100644 --- a/server/types.go +++ b/server/types.go @@ -16,7 +16,7 @@ const ( keepaliveEvent = "keepalive" messageEvent = "message" messageDeleteEvent = "message_delete" - messageReadEvent = "message_read" + messageClearEvent = "message_clear" pollRequestEvent = "poll_request" ) @@ -33,7 +33,7 @@ type message struct { Event string `json:"event"` // One of the above Topic string `json:"topic"` Title string `json:"title,omitempty"` - Message string `json:"message"` // Allow empty message body + Message string `json:"message,omitempty"` Priority int `json:"priority,omitempty"` Tags []string `json:"tags,omitempty"` Click string `json:"click,omitempty"` @@ -161,7 +161,7 @@ func newPollRequestMessage(topic, pollID string) *message { return m } -// newActionMessage creates a new action message (message_delete or message_read) +// newActionMessage creates a new action message (message_delete or message_clear) func newActionMessage(event, topic, sequenceID string) *message { m := newMessage(event, topic, "") m.SequenceID = sequenceID @@ -246,7 +246,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { } func (q *queryFilter) Pass(msg *message) bool { - if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent { return true // filters only apply to messages } else if q.ID != "" && msg.ID != q.ID { return false diff --git a/web/public/sw.js b/web/public/sw.js index 7d6441e3..0dc1afef 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -9,7 +9,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; import { messageWithSequenceId } from "../src/app/utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../src/app/events"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -97,9 +97,9 @@ const handlePushMessageDelete = async (data) => { }; /** - * Handle a message_read event: mark the notification as read. + * Handle a message_clear event: clear/dismiss the notification. */ -const handlePushMessageRead = async (data) => { +const handlePushMessageClear = async (data) => { const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); @@ -159,8 +159,8 @@ const handlePush = async (data) => { await handlePushMessage(data); } else if (data.event === EVENT_MESSAGE_DELETE) { await handlePushMessageDelete(data); - } else if (data.event === EVENT_MESSAGE_READ) { - await handlePushMessageRead(data); + } else if (data.event === EVENT_MESSAGE_CLEAR) { + await handlePushMessageClear(data); } else if (data.event === "subscription_expiring") { await handlePushSubscriptionExpiring(data); } else { diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 06043acc..8e02d6f7 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -52,7 +52,7 @@ class Connection { if (data.event === EVENT_OPEN) { return; } - // Accept message, message_delete, and message_read events + // Accept message, message_delete, and message_clear events const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data; if (!relevantAndValid) { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 56415b76..b455a308 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -66,9 +66,7 @@ class Poller { } // Add only the latest notification for each non-deleted sequence - const notificationsToAdd = Object - .values(latestBySequenceId) - .filter(n => n.event === EVENT_MESSAGE); + const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => n.event === EVENT_MESSAGE); if (notificationsToAdd.length > 0) { console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 2eecde28..41eeba2f 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -3,7 +3,7 @@ import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; import { messageWithSequenceId, topicUrl } from "./utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "./events"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "./events"; class SubscriptionManager { constructor(dbImpl) { @@ -16,7 +16,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), })) ); } @@ -84,7 +84,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null + last: null, }; await this.db.subscriptions.put(subscription); @@ -102,7 +102,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation // May be null! + reservation, // May be null! }); return local.id; @@ -175,7 +175,7 @@ class SubscriptionManager { /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); - if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_READ) { + if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) { return false; } try { @@ -186,13 +186,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSequenceId(notification), subscriptionId, - new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id + last: notification.id, }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -208,7 +208,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId + last: lastNotificationId, }); } @@ -251,19 +251,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil + mutedUntil, }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName + displayName, }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation + reservation, }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index cb65c0b6..172857d9 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -15,7 +15,7 @@ const createDatabase = (username) => { subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]", users: "&baseUrl,username", - prefs: "&key" + prefs: "&key", }); return db; diff --git a/web/src/app/events.js b/web/src/app/events.js index 55dc262c..48537652 100644 --- a/web/src/app/events.js +++ b/web/src/app/events.js @@ -5,10 +5,8 @@ export const EVENT_OPEN = "open"; export const EVENT_KEEPALIVE = "keepalive"; export const EVENT_MESSAGE = "message"; export const EVENT_MESSAGE_DELETE = "message_delete"; -export const EVENT_MESSAGE_READ = "message_read"; +export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_POLL_REQUEST = "poll_request"; // Check if an event is a notification event (message, delete, or read) -export const isNotificationEvent = (event) => - event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_READ; - +export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 5e271b35..2d88f2cf 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -12,7 +12,7 @@ import accountApi from "../app/AccountApi"; import { UnauthorizedError } from "../app/errors"; import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; -import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../app/events"; +import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -57,7 +57,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { // Handle delete: remove notification from database await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); - } else if (notification.event === EVENT_MESSAGE_READ && notification.sequence_id) { + } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) { // Handle read: mark notification as read await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); } else { From 44f20f6b4c367a7ad6e7709ff35d819c3e93c771 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 13 Jan 2026 20:50:31 -0500 Subject: [PATCH 075/548] Add tests, fix firebase --- server/server_firebase.go | 10 ++ server/server_firebase_test.go | 3 + server/server_test.go | 210 ++++++++++++++++++++++++++++- web/src/app/SubscriptionManager.js | 7 +- web/src/app/notificationUtils.js | 2 +- web/src/app/utils.js | 6 +- 6 files changed, 229 insertions(+), 9 deletions(-) diff --git a/server/server_firebase.go b/server/server_firebase.go index 13e80b93..9fde63a3 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -143,6 +143,15 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro "poll_id": m.PollID, } apnsConfig = createAPNSAlertConfig(m, data) + case messageDeleteEvent, messageClearEvent: + data = map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + "sequence_id": m.SequenceID, + } + apnsConfig = createAPNSBackgroundConfig(data) case messageEvent: if auther != nil { // If "anonymous read" for a topic is not allowed, we cannot send the message along @@ -161,6 +170,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro "time": fmt.Sprintf("%d", m.Time), "event": m.Event, "topic": m.Topic, + "sequence_id": m.SequenceID, "priority": fmt.Sprintf("%d", m.Priority), "tags": strings.Join(m.Tags, ","), "click": m.Click, diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index 89004cd3..c98f528f 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -177,6 +177,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { "time": fmt.Sprintf("%d", m.Time), "event": "message", "topic": "mytopic", + "sequence_id": "", "priority": "4", "tags": strings.Join(m.Tags, ","), "click": "https://google.com", @@ -199,6 +200,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { "time": fmt.Sprintf("%d", m.Time), "event": "message", "topic": "mytopic", + "sequence_id": "", "priority": "4", "tags": strings.Join(m.Tags, ","), "click": "https://google.com", @@ -232,6 +234,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) { "time": fmt.Sprintf("%d", m.Time), "event": "poll_request", "topic": "mytopic", + "sequence_id": "", "message": "New message", "title": "", "tags": "", diff --git a/server/server_test.go b/server/server_test.go index 964d6156..530d9458 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -8,8 +8,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "golang.org/x/crypto/bcrypt" - "heckel.io/ntfy/v2/user" "io" "net/http" "net/http/httptest" @@ -24,7 +22,9 @@ import ( "time" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -3289,6 +3289,212 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) { require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") } +func TestServer_DeleteMessage(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a message with a sequence ID + response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "seq123", msg.SequenceID) + require.Equal(t, "message", msg.Event) + + // Delete the message using DELETE method + response = request(t, s, "DELETE", "/mytopic/seq123", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq123", deleteMsg.SequenceID) + require.Equal(t, "message_delete", deleteMsg.Event) + + // Poll and verify both messages are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + msg1 := toMessage(t, lines[0]) + msg2 := toMessage(t, lines[1]) + require.Equal(t, "message", msg1.Event) + require.Equal(t, "message_delete", msg2.Event) + require.Equal(t, "seq123", msg1.SequenceID) + require.Equal(t, "seq123", msg2.SequenceID) +} + +func TestServer_ClearMessage(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a message with a sequence ID + response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "seq456", msg.SequenceID) + require.Equal(t, "message", msg.Event) + + // Clear the message using PUT /topic/seq/clear + response = request(t, s, "PUT", "/mytopic/seq456/clear", "", nil) + require.Equal(t, 200, response.Code) + clearMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq456", clearMsg.SequenceID) + require.Equal(t, "message_clear", clearMsg.Event) + + // Poll and verify both messages are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + msg1 := toMessage(t, lines[0]) + msg2 := toMessage(t, lines[1]) + require.Equal(t, "message", msg1.Event) + require.Equal(t, "message_clear", msg2.Event) + require.Equal(t, "seq456", msg1.SequenceID) + require.Equal(t, "seq456", msg2.SequenceID) +} + +func TestServer_ClearMessage_ReadEndpoint(t *testing.T) { + // Test that /topic/seq/read also works + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a message + response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil) + require.Equal(t, 200, response.Code) + + // Clear using /read endpoint + response = request(t, s, "PUT", "/mytopic/seq789/read", "", nil) + require.Equal(t, 200, response.Code) + clearMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq789", clearMsg.SequenceID) + require.Equal(t, "message_clear", clearMsg.Event) +} + +func TestServer_UpdateMessage(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish original message + response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.Equal(t, "update-seq", msg1.SequenceID) + require.Equal(t, "original message", msg1.Message) + + // Update the message (same sequence ID, new content) + response = request(t, s, "PUT", "/mytopic/update-seq", "updated message", nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, "update-seq", msg2.SequenceID) + require.Equal(t, "updated message", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs + + // Poll and verify both versions are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + polledMsg1 := toMessage(t, lines[0]) + polledMsg2 := toMessage(t, lines[1]) + require.Equal(t, "original message", polledMsg1.Message) + require.Equal(t, "updated message", polledMsg2.Message) + require.Equal(t, "update-seq", polledMsg1.SequenceID) + require.Equal(t, "update-seq", polledMsg2.SequenceID) +} + +func TestServer_UpdateMessage_UsingMessageID(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish original message without a sequence ID + response := request(t, s, "PUT", "/mytopic", "original message", nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg1.ID) + require.Empty(t, msg1.SequenceID) // No sequence ID provided + require.Equal(t, "original message", msg1.Message) + + // Update the message using the message ID as the sequence ID + response = request(t, s, "PUT", "/mytopic/"+msg1.ID, "updated message", nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID + require.Equal(t, "updated message", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs + + // Poll and verify both versions are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + polledMsg1 := toMessage(t, lines[0]) + polledMsg2 := toMessage(t, lines[1]) + require.Equal(t, "original message", polledMsg1.Message) + require.Equal(t, "updated message", polledMsg2.Message) + require.Empty(t, polledMsg1.SequenceID) // Original has no sequence ID + require.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID +} + +func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Test invalid sequence ID for delete (returns 404 because route doesn't match) + response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil) + require.Equal(t, 404, response.Code) + + // Test invalid sequence ID for clear (returns 404 because route doesn't match) + response = request(t, s, "PUT", "/mytopic/invalid*seq/clear", "", nil) + require.Equal(t, 404, response.Code) +} + +func TestServer_DeleteMessage_WithFirebase(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + + // Publish a message + response := request(t, s, "PUT", "/mytopic/firebase-seq", "test message", nil) + require.Equal(t, 200, response.Code) + + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 1, len(sender.Messages())) + require.Equal(t, "message", sender.Messages()[0].Data["event"]) + + // Delete the message + response = request(t, s, "DELETE", "/mytopic/firebase-seq", "", nil) + require.Equal(t, 200, response.Code) + + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 2, len(sender.Messages())) + require.Equal(t, "message_delete", sender.Messages()[1].Data["event"]) + require.Equal(t, "firebase-seq", sender.Messages()[1].Data["sequence_id"]) +} + +func TestServer_ClearMessage_WithFirebase(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + + // Publish a message + response := request(t, s, "PUT", "/mytopic/firebase-clear-seq", "test message", nil) + require.Equal(t, 200, response.Code) + + time.Sleep(100 * time.Millisecond) + require.Equal(t, 1, len(sender.Messages())) + + // Clear the message + response = request(t, s, "PUT", "/mytopic/firebase-clear-seq/clear", "", nil) + require.Equal(t, 200, response.Code) + + time.Sleep(100 * time.Millisecond) + require.Equal(t, 2, len(sender.Messages())) + require.Equal(t, "message_clear", sender.Messages()[1].Data["event"]) + require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"]) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 41eeba2f..430c5e2c 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -202,9 +202,10 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { - const notificationsWithSubscriptionId = notifications.map((notification) => { - return { ...messageWithSequenceId(notification), subscriptionId }; - }); + const notificationsWithSubscriptionId = notifications.map((notification) => ({ + ...messageWithSequenceId(notification), + subscriptionId, + })); const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 65b5bd3d..2d80e0be 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -50,7 +50,7 @@ export const isImage = (attachment) => { export const icon = "/static/images/ntfy.png"; export const badge = "/static/images/mask-icon.svg"; -export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => { +export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => { const image = isImage(message.attachment) ? message.attachment.url : undefined; // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 9aeada05..9e095c7e 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -104,10 +104,10 @@ export const maybeActionErrors = (notification) => { }; export const messageWithSequenceId = (message) => { - if (!message.sequenceId) { - message.sequenceId = message.sequence_id || message.id; + if (message.sequenceId) { + return message; } - return message; + return { ...message, sequenceId: message.sequence_id || message.id }; }; export const shuffle = (arr) => { From dd9b36cf0a1ff1a048579deabad046d20ba10c74 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 13 Jan 2026 21:29:44 -0500 Subject: [PATCH 076/548] Fix db crash --- web/src/app/db.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/src/app/db.js b/web/src/app/db.js index 172857d9..e088a267 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -18,6 +18,13 @@ const createDatabase = (username) => { prefs: "&key", }); + // When another connection (e.g., service worker or another tab) wants to upgrade, + // close this connection gracefully to allow the upgrade to proceed + db.on("versionchange", () => { + console.log("[db] versionchange event: closing database"); + db.close(); + }); + return db; }; From 96638b516c3c1ee05b7f760523ab3518ca2a3c34 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 14 Jan 2026 20:46:18 -0500 Subject: [PATCH 077/548] Fix service worker handling for updating/deleting --- server/server.go | 6 ++-- web/public/sw.js | 52 +++++++++++++----------------- web/src/app/SubscriptionManager.js | 5 +-- web/src/app/events.js | 1 + web/src/app/notificationUtils.js | 13 ++++++-- web/src/app/utils.js | 7 ---- web/src/registerSW.js | 22 +++++++++++-- 7 files changed, 59 insertions(+), 47 deletions(-) diff --git a/server/server.go b/server/server.go index 7ef43275..3bd53ea6 100644 --- a/server/server.go +++ b/server/server.go @@ -86,8 +86,6 @@ var ( webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" - webRootHTMLPath = "/app.html" - webServiceWorkerPath = "/sw.js" accountPath = "/account" matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" @@ -111,7 +109,7 @@ var ( apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}" apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`) apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`) - staticRegex = regexp.MustCompile(`^/static/.+`) + staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) @@ -534,7 +532,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil { return s.handleMetrics(w, r, v) - } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) { + } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleDocs)(w, r, v) diff --git a/web/public/sw.js b/web/public/sw.js index 0dc1afef..07a89415 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -3,13 +3,10 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from import { NavigationRoute, registerRoute } from "workbox-routing"; import { NetworkFirst } from "workbox-strategies"; import { clientsClaim } from "workbox-core"; - import { dbAsync } from "../src/app/db"; - -import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; +import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; -import { messageWithSequenceId } from "../src/app/utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src/app/events"; +import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE, EVENT_SUBSCRIPTION_EXPIRING } from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -23,10 +20,17 @@ import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src const broadcastChannel = new BroadcastChannel("web-push-broadcast"); -const addNotification = async ({ subscriptionId, message }) => { +/** + * Handle a received web push message and show notification. + * + * Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running) + * receives the broadcast and plays a sound (see web/src/app/WebPush.js). + */ +const handlePushMessage = async (data) => { + const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); - // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too + console.log("[ServiceWorker] Message received", data); // Delete existing notification with same sequence ID (if any) const sequenceId = message.sequence_id || message.id; @@ -46,22 +50,9 @@ const addNotification = async ({ subscriptionId, message }) => { last: message.id, }); + // Update badge in PWA const badgeCount = await db.notifications.where({ new: 1 }).count(); - console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); self.navigator.setAppBadge?.(badgeCount); -}; - -/** - * Handle a received web push message and show notification. - * - * Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running) - * receives the broadcast and plays a sound (see web/src/app/WebPush.js). - */ -const handlePushMessage = async (data) => { - const { subscription_id: subscriptionId, message } = data; - - // Add notification to database - await addNotification({ subscriptionId, message }); // Broadcast the message to potentially play a sound broadcastChannel.postMessage(message); @@ -82,11 +73,11 @@ const handlePushMessage = async (data) => { const handlePushMessageDelete = async (data) => { const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); + console.log("[ServiceWorker] Deleting notification sequence", data); // Delete notification with the same sequence_id const sequenceId = message.sequence_id; if (sequenceId) { - console.log("[ServiceWorker] Deleting notification with sequenceId", { subscriptionId, sequenceId }); await db.notifications.where({ subscriptionId, sequenceId }).delete(); } @@ -102,11 +93,11 @@ const handlePushMessageDelete = async (data) => { const handlePushMessageClear = async (data) => { const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); + console.log("[ServiceWorker] Marking notification as read", data); // Mark notification as read (set new = 0) const sequenceId = message.sequence_id; if (sequenceId) { - console.log("[ServiceWorker] Marking notification as read", { subscriptionId, sequenceId }); await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); } @@ -126,6 +117,7 @@ const handlePushMessageClear = async (data) => { */ const handlePushSubscriptionExpiring = async (data) => { const t = await initI18n(); + console.log("[ServiceWorker] Handling incoming subscription expiring event", data); await self.registration.showNotification(t("web_push_subscription_expiring_title"), { body: t("web_push_subscription_expiring_body"), @@ -141,6 +133,7 @@ const handlePushSubscriptionExpiring = async (data) => { */ const handlePushUnknown = async (data) => { const t = await initI18n(); + console.log("[ServiceWorker] Unknown event received", data); await self.registration.showNotification(t("web_push_unknown_notification_title"), { body: t("web_push_unknown_notification_body"), @@ -155,13 +148,15 @@ const handlePushUnknown = async (data) => { * @param {object} data see server/types.go, type webPushPayload */ const handlePush = async (data) => { - if (data.event === EVENT_MESSAGE) { + const { message } = data; + + if (message.event === EVENT_MESSAGE) { await handlePushMessage(data); - } else if (data.event === EVENT_MESSAGE_DELETE) { + } else if (message.event === EVENT_MESSAGE_DELETE) { await handlePushMessageDelete(data); - } else if (data.event === EVENT_MESSAGE_CLEAR) { + } else if (message.event === EVENT_MESSAGE_CLEAR) { await handlePushMessageClear(data); - } else if (data.event === "subscription_expiring") { + } else if (message.event === EVENT_SUBSCRIPTION_EXPIRING) { await handlePushSubscriptionExpiring(data); } else { await handlePushUnknown(data); @@ -176,10 +171,8 @@ const handleClick = async (event) => { const t = await initI18n(); const clients = await self.clients.matchAll({ type: "window" }); - const rootUrl = new URL(self.location.origin); const rootClient = clients.find((client) => client.url === rootUrl.toString()); - // perhaps open on another topic const fallbackClient = clients[0]; if (!event.notification.data?.message) { @@ -295,6 +288,7 @@ precacheAndRoute( // Claim all open windows clientsClaim(); + // Delete any cached old dist files from previous service worker versions cleanupOutdatedCaches(); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 430c5e2c..f909778e 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -2,8 +2,9 @@ import api from "./Api"; import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; -import { messageWithSequenceId, topicUrl } from "./utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "./events"; +import { topicUrl } from "./utils"; +import { messageWithSequenceId } from "./notificationUtils"; +import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events"; class SubscriptionManager { constructor(dbImpl) { diff --git a/web/src/app/events.js b/web/src/app/events.js index 48537652..94d7dc79 100644 --- a/web/src/app/events.js +++ b/web/src/app/events.js @@ -7,6 +7,7 @@ export const EVENT_MESSAGE = "message"; export const EVENT_MESSAGE_DELETE = "message_delete"; export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_POLL_REQUEST = "poll_request"; +export const EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; // Check if an event is a notification event (message, delete, or read) export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR; diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 2d80e0be..a9b8f8ff 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -25,13 +25,13 @@ const formatTitleWithDefault = (m, fallback) => { export const formatMessage = (m) => { if (m.title) { - return m.message; + return m.message || ""; } const emojiList = toEmojis(m.tags); if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.message}`; + return `${emojiList.join(" ")} ${m.message || ""}`; } - return m.message; + return m.message || ""; }; const imageRegex = /\.(png|jpe?g|gif|webp)$/i; @@ -79,3 +79,10 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => { }, ]; }; + +export const messageWithSequenceId = (message) => { + if (message.sequenceId) { + return message; + } + return { ...message, sequenceId: message.sequence_id || message.id }; +}; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 9e095c7e..935f2024 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -103,13 +103,6 @@ export const maybeActionErrors = (notification) => { return actionErrors; }; -export const messageWithSequenceId = (message) => { - if (message.sequenceId) { - return message; - } - return { ...message, sequenceId: message.sequence_id || message.id }; -}; - export const shuffle = (arr) => { const returnArr = [...arr]; diff --git a/web/src/registerSW.js b/web/src/registerSW.js index adef4746..5ae85628 100644 --- a/web/src/registerSW.js +++ b/web/src/registerSW.js @@ -5,10 +5,21 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register"; const intervalMS = 60 * 60 * 1000; // https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html -const registerSW = () => +const registerSW = () => { + console.log("[ServiceWorker] Registering service worker"); + console.log("[ServiceWorker] serviceWorker in navigator:", "serviceWorker" in navigator); + + if (!("serviceWorker" in navigator)) { + console.warn("[ServiceWorker] Service workers not supported"); + return; + } + viteRegisterSW({ onRegisteredSW(swUrl, registration) { + console.log("[ServiceWorker] Registered:", { swUrl, registration }); + if (!registration) { + console.warn("[ServiceWorker] No registration returned"); return; } @@ -23,9 +34,16 @@ const registerSW = () => }, }); - if (resp?.status === 200) await registration.update(); + if (resp?.status === 200) { + console.log("[ServiceWorker] Updating service worker"); + await registration.update(); + } }, intervalMS); }, + onRegisterError(error) { + console.error("[ServiceWorker] Registration error:", error); + }, }); +}; export default registerSW; From ff2b8167b4d663b7bd2ed2348a6b8db2803b960f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 14 Jan 2026 21:17:21 -0500 Subject: [PATCH 078/548] Make closing notifications work --- web/public/sw.js | 15 ++++++++++++++- web/src/app/Notifier.js | 15 +++++++++++++++ web/src/components/hooks.js | 4 ++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/web/public/sw.js b/web/public/sw.js index 07a89415..db87e7f8 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -81,6 +81,13 @@ const handlePushMessageDelete = async (data) => { await db.notifications.where({ subscriptionId, sequenceId }).delete(); } + // Close browser notification with matching tag + const tag = message.sequence_id || message.id; + if (tag) { + const notifications = await self.registration.getNotifications({ tag }); + notifications.forEach((notification) => notification.close()); + } + // Update subscription last message id (for ?since=... queries) await db.subscriptions.update(subscriptionId, { last: message.id, @@ -101,6 +108,13 @@ const handlePushMessageClear = async (data) => { await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); } + // Close browser notification with matching tag + const tag = message.sequence_id || message.id; + if (tag) { + const notifications = await self.registration.getNotifications({ tag }); + notifications.forEach((notification) => notification.close()); + } + // Update subscription last message id (for ?since=... queries) await db.subscriptions.update(subscriptionId, { last: message.id, @@ -108,7 +122,6 @@ const handlePushMessageClear = async (data) => { // Update badge count const badgeCount = await db.notifications.where({ new: 1 }).count(); - console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); self.navigator.setAppBadge?.(badgeCount); }; diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 77bbdb1e..79089749 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -31,6 +31,21 @@ class Notifier { ); } + async cancel(notification) { + if (!this.supported()) { + return; + } + try { + const tag = notification.sequence_id || notification.id; + console.log(`[Notifier] Cancelling notification with ${tag}`); + const registration = await this.serviceWorkerRegistration(); + const notifications = await registration.getNotifications({ tag }); + notifications.forEach((notification) => notification.close()); + } catch (e) { + console.log(`[Notifier] Error cancelling notification`, e); + } + } + async playSound() { // Play sound const sound = await prefs.sound(); diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 2d88f2cf..1c9c2bff 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -55,11 +55,11 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop // and FirebaseService::handleMessage(). if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { - // Handle delete: remove notification from database await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); + await notifier.cancel(notification); } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) { - // Handle read: mark notification as read await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); + await notifier.cancel(notification); } else { // Regular message: delete existing and add new const sequenceId = notification.sequence_id || notification.id; From 2e39a1329cdf7e30a4abff0a23ac523a6d09820d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 14 Jan 2026 21:48:21 -0500 Subject: [PATCH 079/548] AI docs, needs work --- docs/faq.md | 4 +- docs/publish.md | 161 ++++++++++++++++++++++++++++++++++++---- docs/releases.md | 10 +++ docs/subscribe/api.md | 29 ++++---- web/src/app/Notifier.js | 6 +- 5 files changed, 175 insertions(+), 35 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 5fa5252c..5153c700 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -96,8 +96,8 @@ appreciated. ## Can I email you? Can I DM you on Discord/Matrix? For community support, please use the public channels listed on the [contact page](contact.md). I generally **do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing) -plan (see [paid support](contact.md#paid-support-ntfy-pro-subscribers)), or you are inquiring about business -opportunities (see [general inquiries](contact.md#general-inquiries)). +plan (see [paid support](contact.md#paid-support)), or you are inquiring about business +opportunities (see [other inquiries](contact.md#other-inquiries)). I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions in public forums benefits others, since I can link to the discussion at a later point in time, or other users diff --git a/docs/publish.md b/docs/publish.md index 9c409523..fb0b38fb 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1418,22 +1418,23 @@ The JSON message format closely mirrors the format of the message you can consum (see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of all the supported fields: -| Field | Required | Type | Example | Description | -|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------| -| `topic` | ✔️ | *string* | `topic1` | Target topic name | -| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | -| `title` | - | *string* | `Some title` | Message [title](#message-title) | -| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | -| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | -| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | -| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | -| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) | -| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | -| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | -| `filename` | - | *string* | `file.jpg` | File name of the attachment | -| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | -| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | -| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | +| Field | Required | Type | Example | Description | +|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------| +| `topic` | ✔️ | *string* | `topic1` | Target topic name | +| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | +| `title` | - | *string* | `Some title` | Message [title](#message-title) | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | +| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | +| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | +| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) | +| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | +| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | +| `filename` | - | *string* | `file.jpg` | File name of the attachment | +| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | +| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | +| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | +| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | ## Action buttons _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -2694,6 +2695,134 @@ Here's an example that will open Reddit when the notification is clicked: ])); ``` +## Updating + deleting notifications +_Supported on:_ :material-android: :material-firefox: + +You can update, clear (mark as read), or delete notifications that have already been delivered. This is useful for scenarios +like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. + +The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as +belonging to the same sequence, and clients will update/replace the notification accordingly. + +### Updating notifications +To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous +notification with the new one. + +You can either: + +1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates +2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier + +=== "Using the message ID" + ```bash + # First, publish a message and capture the message ID + $ curl -d "Downloading file..." ntfy.sh/mytopic + {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + $ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi + ``` + +=== "Using a custom sequence ID" + ```bash + # Publish with a custom sequence ID + $ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 + + # Update using the same sequence ID + $ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 + ``` + +=== "Using the X-Sequence-ID header" + ```bash + # Publish with a sequence ID via header + $ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic + + # Update using the same sequence ID + $ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic + ``` + +You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the +`sequence_id` field. + +### Clearing notifications +To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to +`///clear` (or `///read` as an alias): + +=== "Command line (curl)" + ```bash + curl -X PUT ntfy.sh/mytopic/my-download-123/clear + ``` + +=== "HTTP" + ```http + PUT /mytopic/my-download-123/clear HTTP/1.1 + Host: ntfy.sh + ``` + +This publishes a `message_clear` event, which tells clients to: + +- Mark the notification as read in the app +- Dismiss the browser/Android notification + +### Deleting notifications +To delete a notification entirely, send a DELETE request to `//`: + +=== "Command line (curl)" + ```bash + curl -X DELETE ntfy.sh/mytopic/my-download-123 + ``` + +=== "HTTP" + ```http + DELETE /mytopic/my-download-123 HTTP/1.1 + Host: ntfy.sh + ``` + +This publishes a `message_delete` event, which tells clients to: + +- Delete the notification from the database +- Dismiss the browser/Android notification + +!!! info + Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will + reappear as a new message. + +### Full example +Here's a complete example showing the lifecycle of a notification with updates, clearing, and deletion: + +```bash +# 1. Create a notification with a custom sequence ID +$ curl -d "Starting backup..." ntfy.sh/mytopic/backup-2024 + +# 2. Update the notification with progress +$ curl -d "Backup 50% complete..." ntfy.sh/mytopic/backup-2024 + +# 3. Update again when complete +$ curl -d "Backup finished successfully!" ntfy.sh/mytopic/backup-2024 + +# 4. Clear the notification (dismiss from notification drawer) +$ curl -X PUT ntfy.sh/mytopic/backup-2024/clear + +# 5. Later, delete the notification entirely +$ curl -X DELETE ntfy.sh/mytopic/backup-2024 +``` + +When polling the topic, you'll see the complete sequence of events: + +```bash +$ curl -s "ntfy.sh/mytopic/json?poll=1&since=all" | jq . +``` + +```json +{"id":"abc123","time":1673542291,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Starting backup..."} +{"id":"def456","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup 50% complete..."} +{"id":"ghi789","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup finished successfully!"} +{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"backup-2024"} +{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"backup-2024"} +``` + +Clients process these events in order, keeping only the latest state for each sequence ID. + ## Attachments _Supported on:_ :material-android: :material-firefox: diff --git a/docs/releases.md b/docs/releases.md index 20759f15..1d80a039 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1599,12 +1599,22 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.16.x (UNRELEASED) + +**Features:** + +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): You can now update, + clear (mark as read), or delete notifications using a sequence ID. This enables use cases like progress updates, + replacing outdated notifications, or dismissing notifications from all clients. + ### ntfy Android app v1.22.x (UNRELEASED) **Features:** * Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149) * Connection error dialog to help diagnose connection issues +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): Notifications with + the same sequence ID are updated in place, and `message_delete`/`message_clear` events dismiss notifications **Bug fixes + maintenance:** diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md index a52e17f6..a549885b 100644 --- a/docs/subscribe/api.md +++ b/docs/subscribe/api.md @@ -324,20 +324,21 @@ format of the message. It's very straight forward: **Message**: -| Field | Required | Type | Example | Description | -|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | -| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | -| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent | -| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | -| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | -| `message` | - | *string* | `Some message` | Message body; always present in `message` events | -| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | -| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | -| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | -| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | -| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification | -| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | +| Field | Required | Type | Example | Description | +|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | +| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | +| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent | +| `event` | ✔️ | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message` | Message type, typically you'd be only interested in `message` | +| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | +| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications) | +| `message` | - | *string* | `Some message` | Message body; always present in `message` events | +| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | +| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | +| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification | +| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | **Attachment** (part of the message, see [attachments](../publish.md#attachments) for details): diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 79089749..723ec43d 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -26,7 +26,7 @@ class Notifier { subscriptionId: subscription.id, message: notification, defaultTitle, - topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), + topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString() }) ); } @@ -40,7 +40,7 @@ class Notifier { console.log(`[Notifier] Cancelling notification with ${tag}`); const registration = await this.serviceWorkerRegistration(); const notifications = await registration.getNotifications({ tag }); - notifications.forEach((notification) => notification.close()); + notifications.forEach(n => n.close()); } catch (e) { console.log(`[Notifier] Error cancelling notification`, e); } @@ -72,7 +72,7 @@ class Notifier { if (hasWebPushTopics) { return pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlB64ToUint8Array(config.web_push_public_key), + applicationServerKey: urlB64ToUint8Array(config.web_push_public_key) }); } From ea3008c7071491f0dbeb3a10f497c53111c45e2e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 14 Jan 2026 22:18:57 -0500 Subject: [PATCH 080/548] Move, add screenshots --- docs/publish.md | 264 +++++++++--------- docs/static/css/extra.css | 18 +- ...droid-screenshot-notification-update-1.png | Bin 0 -> 82085 bytes ...droid-screenshot-notification-update-2.png | Bin 0 -> 81609 bytes 4 files changed, 145 insertions(+), 137 deletions(-) create mode 100644 docs/static/img/android-screenshot-notification-update-1.png create mode 100644 docs/static/img/android-screenshot-notification-update-2.png diff --git a/docs/publish.md b/docs/publish.md index fb0b38fb..6d5dca26 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -937,6 +937,142 @@ Here's an example with a custom message, tags and a priority: file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); ``` +## Updating + deleting notifications +_Supported on:_ :material-android: :material-firefox: + +!!! info + **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. + +You can **update, clear, or delete notifications** that have already been delivered. This is useful for scenarios +like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. + +The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as +belonging to the same sequence, and clients will update/replace the notification accordingly. + +
+ + +
+ +### Updating notifications +To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous +notification with the new one. + +You can either: + +1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates +2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier + +=== "Using the message ID" + ```bash + # First, publish a message and capture the message ID + $ curl -d "Downloading file..." ntfy.sh/mytopic + {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + $ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi + ``` + +=== "Using a custom sequence ID" + ```bash + # Publish with a custom sequence ID + $ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 + + # Update using the same sequence ID + $ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 + ``` + +=== "Using the X-Sequence-ID header" + ```bash + # Publish with a sequence ID via header + $ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic + + # Update using the same sequence ID + $ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic + ``` + +You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the +`sequence_id` field. + +### Clearing notifications +To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to +`///clear` (or `///read` as an alias): + +=== "Command line (curl)" + ```bash + curl -X PUT ntfy.sh/mytopic/my-download-123/clear + ``` + +=== "HTTP" + ```http + PUT /mytopic/my-download-123/clear HTTP/1.1 + Host: ntfy.sh + ``` + +This publishes a `message_clear` event, which tells clients to: + +- Mark the notification as read in the app +- Dismiss the browser/Android notification + +### Deleting notifications +To delete a notification entirely, send a DELETE request to `//`: + +=== "Command line (curl)" + ```bash + curl -X DELETE ntfy.sh/mytopic/my-download-123 + ``` + +=== "HTTP" + ```http + DELETE /mytopic/my-download-123 HTTP/1.1 + Host: ntfy.sh + ``` + +This publishes a `message_delete` event, which tells clients to: + +- Delete the notification from the database +- Dismiss the browser/Android notification + +!!! info + Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will + reappear as a new message. + +### Full example +Here's a complete example showing the lifecycle of a notification with updates, clearing, and deletion: + +```bash +# 1. Create a notification with a custom sequence ID +$ curl -d "Starting backup..." ntfy.sh/mytopic/backup-2024 + +# 2. Update the notification with progress +$ curl -d "Backup 50% complete..." ntfy.sh/mytopic/backup-2024 + +# 3. Update again when complete +$ curl -d "Backup finished successfully!" ntfy.sh/mytopic/backup-2024 + +# 4. Clear the notification (dismiss from notification drawer) +$ curl -X PUT ntfy.sh/mytopic/backup-2024/clear + +# 5. Later, delete the notification entirely +$ curl -X DELETE ntfy.sh/mytopic/backup-2024 +``` + +When polling the topic, you'll see the complete sequence of events: + +```bash +$ curl -s "ntfy.sh/mytopic/json?poll=1&since=all" | jq . +``` + +```json +{"id":"abc123","time":1673542291,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Starting backup..."} +{"id":"def456","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup 50% complete..."} +{"id":"ghi789","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup finished successfully!"} +{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"backup-2024"} +{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"backup-2024"} +``` + +Clients process these events in order, keeping only the latest state for each sequence ID. + ## Message templating _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -2695,134 +2831,6 @@ Here's an example that will open Reddit when the notification is clicked: ])); ``` -## Updating + deleting notifications -_Supported on:_ :material-android: :material-firefox: - -You can update, clear (mark as read), or delete notifications that have already been delivered. This is useful for scenarios -like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. - -The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as -belonging to the same sequence, and clients will update/replace the notification accordingly. - -### Updating notifications -To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous -notification with the new one. - -You can either: - -1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates -2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier - -=== "Using the message ID" - ```bash - # First, publish a message and capture the message ID - $ curl -d "Downloading file..." ntfy.sh/mytopic - {"id":"xE73Iyuabi","time":1673542291,...} - - # Then use the message ID to update it - $ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi - ``` - -=== "Using a custom sequence ID" - ```bash - # Publish with a custom sequence ID - $ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 - - # Update using the same sequence ID - $ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 - ``` - -=== "Using the X-Sequence-ID header" - ```bash - # Publish with a sequence ID via header - $ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic - - # Update using the same sequence ID - $ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic - ``` - -You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the -`sequence_id` field. - -### Clearing notifications -To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to -`///clear` (or `///read` as an alias): - -=== "Command line (curl)" - ```bash - curl -X PUT ntfy.sh/mytopic/my-download-123/clear - ``` - -=== "HTTP" - ```http - PUT /mytopic/my-download-123/clear HTTP/1.1 - Host: ntfy.sh - ``` - -This publishes a `message_clear` event, which tells clients to: - -- Mark the notification as read in the app -- Dismiss the browser/Android notification - -### Deleting notifications -To delete a notification entirely, send a DELETE request to `//`: - -=== "Command line (curl)" - ```bash - curl -X DELETE ntfy.sh/mytopic/my-download-123 - ``` - -=== "HTTP" - ```http - DELETE /mytopic/my-download-123 HTTP/1.1 - Host: ntfy.sh - ``` - -This publishes a `message_delete` event, which tells clients to: - -- Delete the notification from the database -- Dismiss the browser/Android notification - -!!! info - Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will - reappear as a new message. - -### Full example -Here's a complete example showing the lifecycle of a notification with updates, clearing, and deletion: - -```bash -# 1. Create a notification with a custom sequence ID -$ curl -d "Starting backup..." ntfy.sh/mytopic/backup-2024 - -# 2. Update the notification with progress -$ curl -d "Backup 50% complete..." ntfy.sh/mytopic/backup-2024 - -# 3. Update again when complete -$ curl -d "Backup finished successfully!" ntfy.sh/mytopic/backup-2024 - -# 4. Clear the notification (dismiss from notification drawer) -$ curl -X PUT ntfy.sh/mytopic/backup-2024/clear - -# 5. Later, delete the notification entirely -$ curl -X DELETE ntfy.sh/mytopic/backup-2024 -``` - -When polling the topic, you'll see the complete sequence of events: - -```bash -$ curl -s "ntfy.sh/mytopic/json?poll=1&since=all" | jq . -``` - -```json -{"id":"abc123","time":1673542291,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Starting backup..."} -{"id":"def456","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup 50% complete..."} -{"id":"ghi789","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup finished successfully!"} -{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"backup-2024"} -{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"backup-2024"} -``` - -Clients process these events in order, keeping only the latest state for each sequence ID. - ## Attachments _Supported on:_ :material-android: :material-firefox: diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css index 3c53aed6..4577ccce 100644 --- a/docs/static/css/extra.css +++ b/docs/static/css/extra.css @@ -1,10 +1,10 @@ :root > * { - --md-primary-fg-color: #338574; + --md-primary-fg-color: #338574; --md-primary-fg-color--light: #338574; - --md-primary-fg-color--dark: #338574; - --md-footer-bg-color: #353744; - --md-text-font: "Roboto"; - --md-code-font: "Roboto Mono"; + --md-primary-fg-color--dark: #338574; + --md-footer-bg-color: #353744; + --md-text-font: "Roboto"; + --md-code-font: "Roboto Mono"; } .md-header__button.md-logo :is(img, svg) { @@ -34,7 +34,7 @@ figure img, figure video { } header { - background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); + background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%); } body[data-md-color-scheme="default"] header { @@ -93,7 +93,7 @@ figure video { .screenshots img { max-height: 230px; - max-width: 300px; + max-width: 350px; margin: 3px; border-radius: 5px; filter: drop-shadow(2px 2px 2px #ddd); @@ -107,7 +107,7 @@ figure video { opacity: 0; visibility: hidden; position: fixed; - left:0; + left: 0; right: 0; top: 0; bottom: 0; @@ -119,7 +119,7 @@ figure video { } .lightbox.show { - background-color: rgba(0,0,0, 0.75); + background-color: rgba(0, 0, 0, 0.75); opacity: 1; visibility: visible; z-index: 1000; diff --git a/docs/static/img/android-screenshot-notification-update-1.png b/docs/static/img/android-screenshot-notification-update-1.png new file mode 100644 index 0000000000000000000000000000000000000000..16320de4c4e83752b4cb16da0f14e1d929c00852 GIT binary patch literal 82085 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

    {9WD`1bWDJg_I6BUrvjPvt4k4kP^n3r@vy5d7ol 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 L%FxnR#zFrN;V5PV literal 0 HcmV?d00001 From a712d78e4c889754d938c5f4e87e4970c9e5c7b1 Mon Sep 17 00:00:00 2001 From: waclaw66 Date: Wed, 14 Jan 2026 13:26:24 +0100 Subject: [PATCH 081/548] Translated using Weblate (Czech) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cs/ --- web/public/static/langs/cs.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json index 2279cad1..dde71051 100644 --- a/web/public/static/langs/cs.json +++ b/web/public/static/langs/cs.json @@ -403,5 +403,7 @@ "prefs_appearance_theme_light": "Světlý režim", "web_push_subscription_expiring_title": "Oznámení budou pozastavena", "web_push_unknown_notification_title": "Neznámé oznámení přijaté ze serveru", - "web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace" + "web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace", + "account_basics_cannot_edit_or_delete_provisioned_user": "Přiděleného uživatele nelze upravovat ani odstranit", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Nelze upravit ani odstranit přidělený token" } From 3d54260f7932024f99bee94ccfb6828629a51d55 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 09:30:37 -0500 Subject: [PATCH 082/548] Docs --- client/options.go | 5 ++ cmd/publish.go | 6 ++ docs/publish.md | 159 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 150 insertions(+), 20 deletions(-) diff --git a/client/options.go b/client/options.go index f4711834..b99f1673 100644 --- a/client/options.go +++ b/client/options.go @@ -88,6 +88,11 @@ func WithFilename(filename string) PublishOption { return WithHeader("X-Filename", filename) } +// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications +func WithSequenceID(sequenceID string) PublishOption { + return WithHeader("X-Sequence-ID", sequenceID) +} + // WithEmail instructs the server to also send the message to the given e-mail address func WithEmail(email string) PublishOption { return WithHeader("X-Email", email) diff --git a/cmd/publish.go b/cmd/publish.go index f3139a63..c80c140b 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -34,6 +34,7 @@ var flagsPublish = append( &cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"}, &cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"}, &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, + &cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"}, &cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, &cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, @@ -70,6 +71,7 @@ Examples: ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment + ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates echo 'message' | ntfy publish mytopic # Send message from stdin ntfy pub -u phil:mypass secret Psst # Publish with username/password ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing @@ -101,6 +103,7 @@ func execPublish(c *cli.Context) error { markdown := c.Bool("markdown") template := c.String("template") filename := c.String("filename") + sequenceID := c.String("sequence-id") file := c.String("file") email := c.String("email") user := c.String("user") @@ -154,6 +157,9 @@ func execPublish(c *cli.Context) error { if filename != "" { options = append(options, client.WithFilename(filename)) } + if sequenceID != "" { + options = append(options, client.WithSequenceID(sequenceID)) + } if email != "" { options = append(options, client.WithEmail(email)) } diff --git a/docs/publish.md b/docs/publish.md index 6d5dca26..b8afb092 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -963,36 +963,95 @@ You can either: 1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates 2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier -=== "Using the message ID" - ```bash - # First, publish a message and capture the message ID - $ curl -d "Downloading file..." ntfy.sh/mytopic - {"id":"xE73Iyuabi","time":1673542291,...} +Here's an example using a custom sequence ID to update a notification: - # Then use the message ID to update it - $ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi - ``` - -=== "Using a custom sequence ID" +=== "Command line (curl)" ```bash # Publish with a custom sequence ID - $ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 + curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 # Update using the same sequence ID - $ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 + curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 ``` -=== "Using the X-Sequence-ID header" +=== "ntfy CLI" ```bash - # Publish with a sequence ID via header - $ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic + # Publish with a sequence ID + ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..." # Update using the same sequence ID - $ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic + ntfy pub --sequence-id=my-download-123 mytopic "Download complete!" ``` -You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the -`sequence_id` field. +=== "HTTP" + ``` http + POST /mytopic/my-download-123 HTTP/1.1 + Host: ntfy.sh + + Downloading file... + ``` + +=== "JavaScript" + ``` javascript + // First message + await fetch('https://ntfy.sh/mytopic/my-download-123', { + method: 'POST', + body: 'Downloading file...' + }); + + // Update with same sequence ID + await fetch('https://ntfy.sh/mytopic/my-download-123', { + method: 'POST', + body: 'Download complete!' + }); + ``` + +=== "Go" + ``` go + // Publish with sequence ID in URL path + http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", + strings.NewReader("Downloading file...")) + + // Update with same sequence ID + http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", + strings.NewReader("Download complete!")) + ``` + +=== "PowerShell" + ``` powershell + # Publish with sequence ID + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..." + + # Update with same sequence ID + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download complete!" + ``` + +=== "Python" + ``` python + import requests + + # Publish with sequence ID + requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...") + + # Update with same sequence ID + requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download complete!") + ``` + +=== "PHP" + ``` php-inline + // Publish with sequence ID + file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] + ])); + + // Update with same sequence ID + file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Download complete!'] + ])); + ``` + +You can also set the sequence ID via the `X-Sequence-ID` header, the `sid` query parameter, or when +[publishing as JSON](#publish-as-json) using the `sequence_id` field. ### Clearing notifications To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to @@ -1004,11 +1063,41 @@ To clear a notification (mark it as read and dismiss it from the notification dr ``` === "HTTP" - ```http + ``` http PUT /mytopic/my-download-123/clear HTTP/1.1 Host: ntfy.sh ``` +=== "JavaScript" + ``` javascript + await fetch('https://ntfy.sh/mytopic/my-download-123/clear', { + method: 'PUT' + }); + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("PUT", "https://ntfy.sh/mytopic/my-download-123/clear", nil) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod -Method PUT -Uri "https://ntfy.sh/mytopic/my-download-123/clear" + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/mytopic/my-download-123/clear") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([ + 'http' => ['method' => 'PUT'] + ])); + ``` + This publishes a `message_clear` event, which tells clients to: - Mark the notification as read in the app @@ -1023,11 +1112,41 @@ To delete a notification entirely, send a DELETE request to `// ['method' => 'DELETE'] + ])); + ``` + This publishes a `message_delete` event, which tells clients to: - Delete the notification from the database From db4a4776d3639d696011b907645112aa37903db7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 10:07:05 -0500 Subject: [PATCH 083/548] Reword --- docs/publish.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index b8afb092..55788dcd 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -946,7 +946,11 @@ _Supported on:_ :material-android: :material-firefox: You can **update, clear, or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. -The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as +The way this works is that when you publish a message with a specific **sequence ID**, clients that receive the +notification will treat it as part of a sequence. When a new message with the same sequence ID is published, clients +will update the existing notification instead of creating a new one. You can also + +The key concept is the **sequence ID**: notifications with the same sequence ID are treated as belonging to the same sequence, and clients will update/replace the notification accordingly.
    @@ -963,7 +967,87 @@ You can either: 1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates 2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier -Here's an example using a custom sequence ID to update a notification: +If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned +message `id` to update it. The message ID of the first message will then serve as the sequence ID for subsequent updates: + +=== "Command line (curl)" + ```bash + # First, publish a message and capture the message ID + curl -d "Downloading file..." ntfy.sh/mytopic + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi + ``` + +=== "ntfy CLI" + ```bash + # First, publish a message and capture the message ID + ntfy pub mytopic "Downloading file..." + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + ntfy pub --sequence-id=xE73Iyuabi mytopic "Download complete!" + ``` + +=== "JavaScript" + ``` javascript + // First, publish and get the message ID + const response = await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + body: 'Downloading file...' + }); + const { id } = await response.json(); + + // Then use the message ID to update + await fetch(`https://ntfy.sh/mytopic/${id}`, { + method: 'POST', + body: 'Download complete!' + }); + ``` + +=== "Go" + ``` go + // Publish and parse the response to get the message ID + resp, _ := http.Post("https://ntfy.sh/mytopic", "text/plain", + strings.NewReader("Downloading file...")) + var msg struct { ID string `json:"id"` } + json.NewDecoder(resp.Body).Decode(&msg) + + // Update using the message ID + http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain", + strings.NewReader("Download complete!")) + ``` + +=== "Python" + ``` python + import requests + + # Publish and get the message ID + response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...") + message_id = response.json()["id"] + + # Update using the message ID + requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download complete!") + ``` + +=== "PHP" + ``` php-inline + // Publish and get the message ID + $response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] + ])); + $messageId = json_decode($response)->id; + + // Update using the message ID + file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Download complete!'] + ])); + ``` + +You can also use a custom sequence ID (e.g., a download ID, job ID, etc.) when publishing the first message. This is +less cumbersome, since you don't need to capture the message ID first. Just publish directly to +`//`: === "Command line (curl)" ```bash From e81be48bf302a6b2e40d47a77f370f8009cb48b5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 10:32:48 -0500 Subject: [PATCH 084/548] MOre docs update --- docs/publish.md | 183 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 148 insertions(+), 35 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 55788dcd..0652fbd8 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -943,16 +943,17 @@ _Supported on:_ :material-android: :material-firefox: !!! info **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. -You can **update, clear, or delete notifications** that have already been delivered. This is useful for scenarios +You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. +* Updating notifications will alter the content of an existing notification. +* Clearing notifications will mark them as read and dismiss them from the notification drawer. +* Deleting notifications will remove them from the notification drawer and remove them in the clients as well (if supported). + The way this works is that when you publish a message with a specific **sequence ID**, clients that receive the notification will treat it as part of a sequence. When a new message with the same sequence ID is published, clients will update the existing notification instead of creating a new one. You can also -The key concept is the **sequence ID**: notifications with the same sequence ID are treated as -belonging to the same sequence, and clients will update/replace the notification accordingly. -
    @@ -960,12 +961,11 @@ belonging to the same sequence, and clients will update/replace the notification ### Updating notifications To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous -notification with the new one. +notification with the new one. You can either: -You can either: - -1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates -2. **Use a custom sequence ID**: Publish directly to `//` with your own identifier +1. **Use the message ID**: First publish like normal to `POST /` without a sequence ID, then use the returned message `id` as the sequence ID for updates +2. **Use a custom sequence ID**: Publish directly to `POST //` with your own identifier, or use `POST /` with the + `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`) If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned message `id` to update it. The message ID of the first message will then serve as the sequence ID for subsequent updates: @@ -976,8 +976,11 @@ message `id` to update it. The message ID of the first message will then serve a curl -d "Downloading file..." ntfy.sh/mytopic # Returns: {"id":"xE73Iyuabi","time":1673542291,...} - # Then use the message ID to update it - curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi + # Then use the message ID to update it (via URL path) + curl -d "Download 50% ..." ntfy.sh/mytopic/xE73Iyuabi + + # Or update using the X-Sequence-ID header + curl -H "X-Sequence-ID: xE73Iyuabi" -d "Download complete" ntfy.sh/mytopic ``` === "ntfy CLI" @@ -987,7 +990,34 @@ message `id` to update it. The message ID of the first message will then serve a # Returns: {"id":"xE73Iyuabi","time":1673542291,...} # Then use the message ID to update it - ntfy pub --sequence-id=xE73Iyuabi mytopic "Download complete!" + ntfy pub --sequence-id=xE73Iyuabi mytopic "Download 50% ..." + + # Update again with the same sequence ID + ntfy pub -S xE73Iyuabi mytopic "Download complete" + ``` + +=== "HTTP" + ``` http + # First, publish a message and capture the message ID + POST /mytopic HTTP/1.1 + Host: ntfy.sh + + Downloading file... + + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + POST /mytopic/xE73Iyuabi HTTP/1.1 + Host: ntfy.sh + + Download 50% ... + + # Update again with the same sequence ID, this time using the header + POST /mytopic HTTP/1.1 + Host: ntfy.sh + X-Sequence-ID: xE73Iyuabi + + Download complete ``` === "JavaScript" @@ -999,10 +1029,17 @@ message `id` to update it. The message ID of the first message will then serve a }); const { id } = await response.json(); - // Then use the message ID to update + // Update via URL path await fetch(`https://ntfy.sh/mytopic/${id}`, { method: 'POST', - body: 'Download complete!' + body: 'Download 50% ...' + }); + + // Or update using the X-Sequence-ID header + await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + headers: { 'X-Sequence-ID': id }, + body: 'Download complete' }); ``` @@ -1014,9 +1051,29 @@ message `id` to update it. The message ID of the first message will then serve a var msg struct { ID string `json:"id"` } json.NewDecoder(resp.Body).Decode(&msg) - // Update using the message ID + // Update via URL path http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain", - strings.NewReader("Download complete!")) + strings.NewReader("Download 50% ...")) + + // Or update using the X-Sequence-ID header + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", + strings.NewReader("Download complete")) + req.Header.Set("X-Sequence-ID", msg.ID) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Publish and get the message ID + $response = Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" -Body "Downloading file..." + $messageId = $response.id + + # Update via URL path + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/$messageId" -Body "Download 50% ..." + + # Or update using the X-Sequence-ID header + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" ` + -Headers @{"X-Sequence-ID"=$messageId} -Body "Download complete" ``` === "Python" @@ -1027,8 +1084,12 @@ message `id` to update it. The message ID of the first message will then serve a response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...") message_id = response.json()["id"] - # Update using the message ID - requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download complete!") + # Update via URL path + requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download 50% ...") + + # Or update using the X-Sequence-ID header + requests.post("https://ntfy.sh/mytopic", + headers={"X-Sequence-ID": message_id}, data="Download complete") ``` === "PHP" @@ -1039,9 +1100,18 @@ message `id` to update it. The message ID of the first message will then serve a ])); $messageId = json_decode($response)->id; - // Update using the message ID + // Update via URL path file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([ - 'http' => ['method' => 'POST', 'content' => 'Download complete!'] + 'http' => ['method' => 'POST', 'content' => 'Download 50% ...'] + ])); + + // Or update using the X-Sequence-ID header + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "X-Sequence-ID: $messageId", + 'content' => 'Download complete' + ] ])); ``` @@ -1054,8 +1124,11 @@ less cumbersome, since you don't need to capture the message ID first. Just publ # Publish with a custom sequence ID curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 - # Update using the same sequence ID - curl -d "Download complete!" ntfy.sh/mytopic/my-download-123 + # Update using the same sequence ID (via URL path) + curl -d "Download 50% ..." ntfy.sh/mytopic/my-download-123 + + # Or update using the X-Sequence-ID header + curl -H "X-Sequence-ID: my-download-123" -d "Download complete" ntfy.sh/mytopic ``` === "ntfy CLI" @@ -1064,7 +1137,10 @@ less cumbersome, since you don't need to capture the message ID first. Just publ ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..." # Update using the same sequence ID - ntfy pub --sequence-id=my-download-123 mytopic "Download complete!" + ntfy pub --sequence-id=my-download-123 mytopic "Download 50% ..." + + # Update again + ntfy pub -S my-download-123 mytopic "Download complete" ``` === "HTTP" @@ -1074,6 +1150,13 @@ less cumbersome, since you don't need to capture the message ID first. Just publ Downloading file... ``` + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + X-Sequence-ID: my-download-123 + + Download complete + ``` === "JavaScript" ``` javascript @@ -1083,10 +1166,17 @@ less cumbersome, since you don't need to capture the message ID first. Just publ body: 'Downloading file...' }); - // Update with same sequence ID + // Update via URL path await fetch('https://ntfy.sh/mytopic/my-download-123', { method: 'POST', - body: 'Download complete!' + body: 'Download 50% ...' + }); + + // Or update using the X-Sequence-ID header + await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + headers: { 'X-Sequence-ID': 'my-download-123' }, + body: 'Download complete' }); ``` @@ -1096,9 +1186,15 @@ less cumbersome, since you don't need to capture the message ID first. Just publ http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", strings.NewReader("Downloading file...")) - // Update with same sequence ID + // Update via URL path http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", - strings.NewReader("Download complete!")) + strings.NewReader("Download 50% ...")) + + // Or update using the X-Sequence-ID header + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", + strings.NewReader("Download complete")) + req.Header.Set("X-Sequence-ID", "my-download-123") + http.DefaultClient.Do(req) ``` === "PowerShell" @@ -1106,8 +1202,12 @@ less cumbersome, since you don't need to capture the message ID first. Just publ # Publish with sequence ID Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..." - # Update with same sequence ID - Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download complete!" + # Update via URL path + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download 50% ..." + + # Or update using the X-Sequence-ID header + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" ` + -Headers @{"X-Sequence-ID"="my-download-123"} -Body "Download complete" ``` === "Python" @@ -1117,8 +1217,12 @@ less cumbersome, since you don't need to capture the message ID first. Just publ # Publish with sequence ID requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...") - # Update with same sequence ID - requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download complete!") + # Update via URL path + requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download 50% ...") + + # Or update using the X-Sequence-ID header + requests.post("https://ntfy.sh/mytopic", + headers={"X-Sequence-ID": "my-download-123"}, data="Download complete") ``` === "PHP" @@ -1128,14 +1232,23 @@ less cumbersome, since you don't need to capture the message ID first. Just publ 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] ])); - // Update with same sequence ID + // Update via URL path file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ - 'http' => ['method' => 'POST', 'content' => 'Download complete!'] + 'http' => ['method' => 'POST', 'content' => 'Download 50% ...'] + ])); + + // Or update using the X-Sequence-ID header + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => 'X-Sequence-ID: my-download-123', + 'content' => 'Download complete' + ] ])); ``` -You can also set the sequence ID via the `X-Sequence-ID` header, the `sid` query parameter, or when -[publishing as JSON](#publish-as-json) using the `sequence_id` field. +You can also set the sequence ID via the `sid` query parameter, or when [publishing as JSON](#publish-as-json) +using the `sequence_id` field. ### Clearing notifications To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to From 94eb121f38f79d6d4a09c5e259d0c6eac6225f03 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 13:07:07 -0500 Subject: [PATCH 085/548] Docs updates --- docs/publish.md | 104 +++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 0652fbd8..3363063a 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -946,19 +946,24 @@ _Supported on:_ :material-android: :material-firefox: You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. -* Updating notifications will alter the content of an existing notification. -* Clearing notifications will mark them as read and dismiss them from the notification drawer. -* Deleting notifications will remove them from the notification drawer and remove them in the clients as well (if supported). +* [Updating notifications](#updating-notifications) will alter the content of an existing notification. +* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer. +* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported). -The way this works is that when you publish a message with a specific **sequence ID**, clients that receive the -notification will treat it as part of a sequence. When a new message with the same sequence ID is published, clients -will update the existing notification instead of creating a new one. You can also +Here's an example of a download progress notification being updated over time on Android:
    +To facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence, +using a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the +same sequence ID and the clients will perform the appropriate action on the existing notification. + +Existing ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates +the update, clear, or delete action. This append-only behavior ensures that message history remains intact. + ### Updating notifications To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous notification with the new one. You can either: @@ -968,7 +973,7 @@ notification with the new one. You can either: `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`) If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned -message `id` to update it. The message ID of the first message will then serve as the sequence ID for subsequent updates: +message `id` to update it. Here's an example: === "Command line (curl)" ```bash @@ -1115,8 +1120,8 @@ message `id` to update it. The message ID of the first message will then serve a ])); ``` -You can also use a custom sequence ID (e.g., a download ID, job ID, etc.) when publishing the first message. This is -less cumbersome, since you don't need to capture the message ID first. Just publish directly to +You can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message. +**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to `//`: === "Command line (curl)" @@ -1145,12 +1150,13 @@ less cumbersome, since you don't need to capture the message ID first. Just publ === "HTTP" ``` http + # Publish a message with a custom sequence ID POST /mytopic/my-download-123 HTTP/1.1 Host: ntfy.sh Downloading file... - ``` - ``` http + + # Update again using the X-Sequence-ID header POST /mytopic HTTP/1.1 Host: ntfy.sh X-Sequence-ID: my-download-123 @@ -1247,12 +1253,24 @@ less cumbersome, since you don't need to capture the message ID first. Just publ ])); ``` -You can also set the sequence ID via the `sid` query parameter, or when [publishing as JSON](#publish-as-json) -using the `sequence_id` field. +You can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when +[publishing as JSON](#publish-as-json) using the `sequence_id` field. + +If the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id` +field the response. A sequence of updates may look like this (first example from above): + +```json +{"id":"xE73Iyuabi","time":1673542291,"event":"message","topic":"mytopic","message":"Downloading file..."} +{"id":"yF84Jzvbcj","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download 50% ..."} +{"id":"zG95Kawdde","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download complete"} +``` ### Clearing notifications -To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to -`///clear` (or `///read` as an alias): +Clearing a notification means **marking it as read and dismissing it from the notification drawer**. + +To do this, send a PUT request to the `///clear` endpoint (or `///read` as an alias). +This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status +and dismiss the notification. === "Command line (curl)" ```bash @@ -1295,13 +1313,17 @@ To clear a notification (mark it as read and dismiss it from the notification dr ])); ``` -This publishes a `message_clear` event, which tells clients to: +An example response from the server with the `message_clear` event may look like this: -- Mark the notification as read in the app -- Dismiss the browser/Android notification +```json +{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"my-download-123"} +``` ### Deleting notifications -To delete a notification entirely, send a DELETE request to `//`: +Deleting a notification means **removing it from the notification drawer and from the client's database**. + +To do this, send a DELETE request to the `//` endpoint. This will emit a `message_delete` event +that is used by the clients (web app and Android app) to remove the notification entirely. === "Command line (curl)" ```bash @@ -1344,51 +1366,16 @@ To delete a notification entirely, send a DELETE request to `// Date: Thu, 15 Jan 2026 19:06:05 -0500 Subject: [PATCH 086/548] Refine, docs --- web/public/sw.js | 6 ++++++ web/src/app/Notifier.js | 6 +++--- web/src/components/hooks.js | 7 +++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/web/public/sw.js b/web/public/sw.js index db87e7f8..7affd55f 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -163,6 +163,12 @@ const handlePushUnknown = async (data) => { const handlePush = async (data) => { const { message } = data; + // This logic is (partially) duplicated in + // - Android: SubscriberService::onNotificationReceived() + // - Android: FirebaseService::onMessageReceived() + // - Web app: hooks.js:handleNotification() + // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... + if (message.event === EVENT_MESSAGE) { await handlePushMessage(data); } else if (message.event === EVENT_MESSAGE_DELETE) { diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 723ec43d..f6e47a7c 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -26,7 +26,7 @@ class Notifier { subscriptionId: subscription.id, message: notification, defaultTitle, - topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString() + topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), }) ); } @@ -40,7 +40,7 @@ class Notifier { console.log(`[Notifier] Cancelling notification with ${tag}`); const registration = await this.serviceWorkerRegistration(); const notifications = await registration.getNotifications({ tag }); - notifications.forEach(n => n.close()); + notifications.forEach((n) => n.close()); } catch (e) { console.log(`[Notifier] Error cancelling notification`, e); } @@ -72,7 +72,7 @@ class Notifier { if (hasWebPushTopics) { return pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlB64ToUint8Array(config.web_push_public_key) + applicationServerKey: urlB64ToUint8Array(config.web_push_public_key), }); } diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 1c9c2bff..9dadd551 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -51,8 +51,11 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop }; const handleNotification = async (subscriptionId, notification) => { - // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() - // and FirebaseService::handleMessage(). + // This logic is (partially) duplicated in + // - Android: SubscriberService::onNotificationReceived() + // - Android: FirebaseService::onMessageReceived() + // - Web app: hooks.js:handleNotification() + // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); From 2cc4bf7d28ba07cf747c28ace78ef1b7a3e944fa Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 21:55:20 -0500 Subject: [PATCH 087/548] Fix webpush stuff --- docs/releases.md | 22 ++++++++++++++-------- server/errors.go | 5 +++-- web/public/sw.js | 33 ++++++++++++++++++++------------- web/src/app/events.js | 4 +++- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 1d80a039..2f3f669e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1603,22 +1603,28 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): You can now update, - clear (mark as read), or delete notifications using a sequence ID. This enables use cases like progress updates, - replacing outdated notifications, or dismissing notifications from all clients. +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) + ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), + [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) + for the initial implementation) ### ntfy Android app v1.22.x (UNRELEASED) **Features:** -* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149) +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) + ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), + [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) + for the initial implementation) +* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215), + [#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149), + thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing) * Connection error dialog to help diagnose connection issues -* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): Notifications with - the same sequence ID are updated in place, and `message_delete`/`message_clear` events dismiss notifications **Bug fixes + maintenance:** -* Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting) -* Fix crash in sharing dialog (thanks to @rogeliodh) +* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529), + thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing) +* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh)) * Fix crash when exiting multi-delete in detail view * Fix potential crashes with icon downloader and backuper diff --git a/server/errors.go b/server/errors.go index e8f58d75..a29ff27d 100644 --- a/server/errors.go +++ b/server/errors.go @@ -3,8 +3,9 @@ package server import ( "encoding/json" "fmt" - "heckel.io/ntfy/v2/log" "net/http" + + "heckel.io/ntfy/v2/log" ) // errHTTP is a generic HTTP error for any non-200 HTTP error @@ -125,7 +126,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} + errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", 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} diff --git a/web/public/sw.js b/web/public/sw.js index 7affd55f..6b298414 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -6,7 +6,13 @@ import { clientsClaim } from "workbox-core"; import { dbAsync } from "../src/app/db"; import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; -import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE, EVENT_SUBSCRIPTION_EXPIRING } from "../src/app/events"; +import { + EVENT_MESSAGE, + EVENT_MESSAGE_CLEAR, + EVENT_MESSAGE_DELETE, + WEBPUSH_EVENT_MESSAGE, + WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING, +} from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -161,25 +167,26 @@ const handlePushUnknown = async (data) => { * @param {object} data see server/types.go, type webPushPayload */ const handlePush = async (data) => { - const { message } = data; - // This logic is (partially) duplicated in // - Android: SubscriberService::onNotificationReceived() // - Android: FirebaseService::onMessageReceived() // - Web app: hooks.js:handleNotification() // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... - if (message.event === EVENT_MESSAGE) { - await handlePushMessage(data); - } else if (message.event === EVENT_MESSAGE_DELETE) { - await handlePushMessageDelete(data); - } else if (message.event === EVENT_MESSAGE_CLEAR) { - await handlePushMessageClear(data); - } else if (message.event === EVENT_SUBSCRIPTION_EXPIRING) { - await handlePushSubscriptionExpiring(data); - } else { - await handlePushUnknown(data); + if (data.event === WEBPUSH_EVENT_MESSAGE) { + const { message } = data; + if (message.event === EVENT_MESSAGE) { + return await handlePushMessage(data); + } else if (message.event === EVENT_MESSAGE_DELETE) { + return await handlePushMessageDelete(data); + } else if (message.event === EVENT_MESSAGE_CLEAR) { + return await handlePushMessageClear(data); + } + } else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) { + return await handlePushSubscriptionExpiring(data); } + + return await handlePushUnknown(data); }; /** diff --git a/web/src/app/events.js b/web/src/app/events.js index 94d7dc79..d5c5ab88 100644 --- a/web/src/app/events.js +++ b/web/src/app/events.js @@ -7,7 +7,9 @@ export const EVENT_MESSAGE = "message"; export const EVENT_MESSAGE_DELETE = "message_delete"; export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_POLL_REQUEST = "poll_request"; -export const EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; + +export const WEBPUSH_EVENT_MESSAGE = "message"; +export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; // Check if an event is a notification event (message, delete, or read) export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR; From 0ad06e808ffd8e071a6cae252138f49e1d000c20 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 15 Jan 2026 22:14:56 -0500 Subject: [PATCH 088/548] Self-review --- web/src/registerSW.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/registerSW.js b/web/src/registerSW.js index 5ae85628..842cf80e 100644 --- a/web/src/registerSW.js +++ b/web/src/registerSW.js @@ -7,8 +7,6 @@ const intervalMS = 60 * 60 * 1000; // https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html const registerSW = () => { console.log("[ServiceWorker] Registering service worker"); - console.log("[ServiceWorker] serviceWorker in navigator:", "serviceWorker" in navigator); - if (!("serviceWorker" in navigator)) { console.warn("[ServiceWorker] Service workers not supported"); return; From fcf57a04e1651702fef521d452aad47431645c29 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 16 Jan 2026 09:36:27 -0500 Subject: [PATCH 089/548] Move event up --- server/message_cache.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index ec1a395e..c0c1ea78 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -31,6 +31,7 @@ const ( mid TEXT NOT NULL, sequence_id TEXT NOT NULL, time INT NOT NULL, + event TEXT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, @@ -50,8 +51,7 @@ const ( user TEXT NOT NULL, content_type TEXT NOT NULL, encoding TEXT NOT NULL, - published INT NOT NULL, - event TEXT NOT NULL + published INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); @@ -69,50 +69,50 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, event) + INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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, content_type, encoding FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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, content_type, encoding FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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, content_type, encoding FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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, content_type, encoding FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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, content_type, encoding FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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, content_type, encoding FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event + 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, content_type, encoding FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id @@ -410,6 +410,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ID, m.SequenceID, m.Time, + m.Event, m.Expires, m.Topic, m.Message, @@ -430,7 +431,6 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, - m.Event, ) if err != nil { return err @@ -719,11 +719,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 var priority int - var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding, event string + var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string err := rows.Scan( &id, &sequenceID, ×tamp, + &event, &expires, &topic, &msg, @@ -742,7 +743,6 @@ func readMessage(rows *sql.Rows) (*message, error) { &user, &contentType, &encoding, - &event, ) if err != nil { return nil, err From c1ee163cabeec2bfa41e5c139c3b4429a7db1cdf Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 16 Jan 2026 10:07:09 -0500 Subject: [PATCH 090/548] Remove cache thing --- server/message_cache.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index c0c1ea78..342f9687 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -771,10 +771,6 @@ func readMessage(rows *sql.Rows) (*message, error) { URL: attachmentURL, } } - // Clear SequenceID if it equals ID (we do not want the SequenceID in the message output) - if sequenceID == id { - sequenceID = "" - } return &message{ ID: id, SequenceID: sequenceID, From 01435d5fea6759b97aca7e3cbab061fd4f064d81 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 03:20:41 -0500 Subject: [PATCH 091/548] Bump --- go.mod | 14 +++++++------- go.sum | 16 ++++++++++++++++ web/package-lock.json | 18 +++++++++--------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 9ef06c2c..ad61782e 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.24.0 toolchain go1.24.5 require ( - cloud.google.com/go/firestore v1.20.0 // indirect - cloud.google.com/go/storage v1.59.0 // indirect + cloud.google.com/go/firestore v1.21.0 // indirect + cloud.google.com/go/storage v1.59.1 // 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.18.0 @@ -21,7 +21,7 @@ require ( golang.org/x/sync v0.19.0 golang.org/x/term v0.39.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.259.0 + google.golang.org/api v0.260.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -69,7 +69,7 @@ require ( 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.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -95,9 +95,9 @@ require ( golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 // indirect + google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 241b5556..87ba62a2 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,13 @@ cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= 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 v1.53.0 h1:dILGanjePNsYfZVYYv6K0d4+IPnKX1gn84Fk8jDPNvs= 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.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo= cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= +cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM= +cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= @@ -20,6 +23,8 @@ cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhO cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8= cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58= +cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw= @@ -98,6 +103,8 @@ 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.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU= github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -265,14 +272,23 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= +google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= +google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 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-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4= google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= +google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE= +google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 h1:4DKBrmaqeptdEzp21EfrOEh8LE7PJ5ywH6wydSbOfGY= google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= +google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss= +google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 h1:IY6/YYRrFUk0JPp0xOVctvFIVuRnjccihY5kxf5g0TE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/web/package-lock.json b/web/package-lock.json index 89894356..be2bc07a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3702,9 +3702,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", + "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8436,9 +8436,9 @@ } }, "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -9121,9 +9121,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { From 48efdffa578f7573b49a05d052050a48737e135a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 03:29:40 -0500 Subject: [PATCH 092/548] Fix indent --- docs/troubleshooting.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7cdd4cda..9215bad2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -137,7 +137,7 @@ Sorry, there is no way to debug or get the logs from the iOS app (yet), outside If all of your topics are showing as "Reconnecting" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration: * If ntfy is behind a reverse proxy (such as Nginx): - * Make sure `behind_proxy` is enabled in ntfy's config. - * Make sure WebSockets are enabled in the reverse proxy config. + * Make sure `behind-proxy` is enabled in ntfy's config. + * Make sure WebSockets are enabled in the reverse proxy config. * Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON request, so a single topic that receives `403 Forbidden` will prevent the entire request from going through. - * In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush. + * In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush. From 3ce56879ae4def14de2e6ff4dbda0cc1a9c48d04 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 03:49:33 -0500 Subject: [PATCH 093/548] Tidy --- go.sum | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/go.sum b/go.sum index 87ba62a2..77e16c2b 100644 --- a/go.sum +++ b/go.sum @@ -6,11 +6,8 @@ cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= 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 v1.53.0 h1:dILGanjePNsYfZVYYv6K0d4+IPnKX1gn84Fk8jDPNvs= 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.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo= -cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM= cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= @@ -21,8 +18,6 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7 cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8= -cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58= cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= @@ -101,8 +96,6 @@ 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.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU= -github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= @@ -270,23 +263,14 @@ 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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= -google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 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-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4= -google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE= google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= -google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 h1:4DKBrmaqeptdEzp21EfrOEh8LE7PJ5ywH6wydSbOfGY= -google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss= google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 h1:IY6/YYRrFUk0JPp0xOVctvFIVuRnjccihY5kxf5g0TE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= From 6bacf7dafc8b21f589af2ae1b983f6195a3096fc Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 04:34:32 -0500 Subject: [PATCH 094/548] Works --- cmd/serve.go | 1 + docs/config.md | 62 ++++++++++++++++++++++++++++------------- go.sum | 16 ----------- server/server.yml | 2 +- server/server_twilio.go | 62 ++++++++++++++++++++++++++++++++++------- 5 files changed, 97 insertions(+), 46 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index f6eb1475..e4f432d2 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -77,6 +77,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), diff --git a/docs/config.md b/docs/config.md index 56c2ceb8..0b961b1f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1261,30 +1261,54 @@ are the easiest), and then configure the following options: * `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 * `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 * `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 +* `twilio-call-format` is the custom TwiML send to the Call API (optional, see [TwiML](https://www.twilio.com/docs/voice/twiml)) After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. -To customize your message send to Twilio's Call API, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). -This is the default TwiML: +To customize your message send to Twilio's Call API, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is +rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message: -``` xml - - - - You have a message from notify on topic %s. Message: - - %s - - End of message. - - This message was sent by user %s. It will be repeated three times. - To unsubscribe from calls like this, remove your phone number in the notify web app. - - - Goodbye. -` -``` +* `{{.Topic}}` is the topic name +* `{{.Message}}` is the message body +* `{{.Title}}` is the message title +* `{{.Tags}}` is a list of tags +* `{{.Priority}}` is the message priority +* `{{.Sender}}` is the IP address or username of the sender + +Here's an example: + +=== English example + ``` yaml + twilio-account: "AC12345beefbeef67890beefbeef122586" + twilio-auth-token: "affebeef258625862586258625862586" + twilio-phone-number: "+18775132586" + twilio-verify-service: "VA12345beefbeef67890beefbeef122586" + twilio-call-format: | + + + + Du hast eine Nachricht zum Thema {{.Topic}}. + {{ if eq .Priority 5 }} + Achtung. Die Nachricht ist sehr wichtig. + {{ end }} + + {{ if neq .Title "" }} + Titel der Nachricht: {{.Title}}. + {{ end }} + + Nachricht: + + {{.Message}} + + Ende der Nachricht. + + Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt. + + + Alla mol! + + ``` The TwiML is internaly used as a format string: 1. The first `%s` will be replaced with the topic. diff --git a/go.sum b/go.sum index 87ba62a2..77e16c2b 100644 --- a/go.sum +++ b/go.sum @@ -6,11 +6,8 @@ cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= 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 v1.53.0 h1:dILGanjePNsYfZVYYv6K0d4+IPnKX1gn84Fk8jDPNvs= 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.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo= -cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM= cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= @@ -21,8 +18,6 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7 cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8= -cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58= cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= @@ -101,8 +96,6 @@ 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.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU= -github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= @@ -270,23 +263,14 @@ 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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= -google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 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-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4= -google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE= google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= -google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 h1:4DKBrmaqeptdEzp21EfrOEh8LE7PJ5ywH6wydSbOfGY= -google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss= google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 h1:IY6/YYRrFUk0JPp0xOVctvFIVuRnjccihY5kxf5g0TE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= diff --git a/server/server.yml b/server/server.yml index ccab7dea..639ed492 100644 --- a/server/server.yml +++ b/server/server.yml @@ -216,7 +216,7 @@ # - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 # - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586 # - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 -# - twilio-call-format (optional) is the custom TwiML send to the Call API. See: https://www.twilio.com/docs/voice/twiml +# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml) # # twilio-account: # twilio-auth-token: diff --git a/server/server_twilio.go b/server/server_twilio.go index 37015dd6..0c5694d8 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,27 +4,35 @@ import ( "bytes" "encoding/xml" "fmt" - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" "io" "net/http" "net/url" "strings" + "text/template" + + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" ) const ( + // defaultTwilioCallFormat is the default TwiML format used for Twilio calls. + // It can be overridden in the server configuration's twilio-call-format field. + // + // The format uses Go template syntax with the following fields: + // {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}} + // String fields are automatically XML-escaped. defaultTwilioCallFormat = ` - You have a message from notify on topic %s. Message: + You have a message from notify on topic {{.Topic}}. Message: - %s + {{.Message}} End of message. - This message was sent by user %s. It will be repeated three times. + This message was sent by user {{.Sender}}. It will be repeated three times. To unsubscribe from calls like this, remove your phone number in the notify web app. @@ -32,6 +40,16 @@ const ( ` ) +// twilioCallData holds the data passed to the Twilio call format template +type twilioCallData struct { + Topic string + Title string + Message string + Priority int + Tags []string + Sender string +} + // convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified // phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number. // If the user is anonymous, it will return an error. @@ -65,11 +83,35 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { if u != nil { sender = u.Name } - twilioCallFormat := defaultTwilioCallFormat - if len(s.config.TwilioCallFormat) > 0 { - twilioCallFormat = s.config.TwilioCallFormat + templateStr := defaultTwilioCallFormat + if s.config.TwilioCallFormat != "" { + templateStr = s.config.TwilioCallFormat } - body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) + tmpl, err := template.New("twiml").Parse(templateStr) + if err != nil { + logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error parsing Twilio call format template") + minc(metricCallsMadeFailure) + return + } + tags := make([]string, len(m.Tags)) + for i, tag := range m.Tags { + tags[i] = xmlEscapeText(tag) + } + templateData := &twilioCallData{ + Topic: xmlEscapeText(m.Topic), + Title: xmlEscapeText(m.Title), + Message: xmlEscapeText(m.Message), + Priority: m.Priority, + Tags: tags, + Sender: xmlEscapeText(sender), + } + var bodyBuf bytes.Buffer + if err := tmpl.Execute(&bodyBuf, templateData); err != nil { + logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template") + minc(metricCallsMadeFailure) + return + } + body := bodyBuf.String() data := url.Values{} data.Set("From", s.config.TwilioPhoneNumber) data.Set("To", to) From b23f6632b1230ceeec9f13cb4141053da0a432c0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 04:59:46 -0500 Subject: [PATCH 095/548] Use Go templates, update docs --- cmd/serve.go | 9 +++++++- docs/config.md | 41 +++++++++++++++++++++++++++++------- docs/releases.md | 7 +++--- server/config.go | 5 +++-- server/server_twilio.go | 30 ++++++++++---------------- server/server_twilio_test.go | 20 ++++++++++-------- 6 files changed, 69 insertions(+), 43 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index e4f432d2..4d2803d5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -14,6 +14,7 @@ import ( "os/signal" "strings" "syscall" + "text/template" "time" "github.com/urfave/cli/v2" @@ -458,7 +459,13 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService - conf.TwilioCallFormat = twilioCallFormat + if twilioCallFormat != "" { + tmpl, err := template.New("twiml").Parse(twilioCallFormat) + if err != nil { + return fmt.Errorf("failed to parse twilio-call-format template: %w", err) + } + conf.TwilioCallFormat = tmpl + } conf.MessageSizeLimit = int(messageSizeLimit) conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit diff --git a/docs/config.md b/docs/config.md index 0b961b1f..8a125146 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1261,12 +1261,12 @@ are the easiest), and then configure the following options: * `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 * `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 * `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 -* `twilio-call-format` is the custom TwiML send to the Call API (optional, see [TwiML](https://www.twilio.com/docs/voice/twiml)) +* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional) After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. -To customize your message send to Twilio's Call API, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is +To customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message: * `{{.Topic}}` is the topic name @@ -1278,7 +1278,37 @@ rendered as a [Go template](https://pkg.go.dev/text/template), so you can use th Here's an example: -=== English example +=== "Custom TwiML (English)" + ``` yaml + twilio-account: "AC12345beefbeef67890beefbeef122586" + twilio-auth-token: "affebeef258625862586258625862586" + twilio-phone-number: "+18775132586" + twilio-verify-service: "VA12345beefbeef67890beefbeef122586" + twilio-call-format: | + + + + Yo yo yo, you should totally check out this message for {{.Topic}}. + {{ if eq .Priority 5 }} + It's really really important, dude. So listen up! + {{ end }} + + {{ if neq .Title "" }} + Bro, it's titled: {{.Title}}. + {{ end }} + + {{.Message}} + + That is all. + + You know who this message is from? It is from {{.Sender}}. + + + See ya! + + ``` + +=== "Custom TwiML (German)" ``` yaml twilio-account: "AC12345beefbeef67890beefbeef122586" twilio-auth-token: "affebeef258625862586258625862586" @@ -1310,11 +1340,6 @@ Here's an example: ``` -The TwiML is internaly used as a format string: -1. The first `%s` will be replaced with the topic. -1. The second `%s` will be replaced with the message. -1. The third `%s` will be replaced with the message`s sender name. - ## Message limits There are a few message limits that you can configure: diff --git a/docs/releases.md b/docs/releases.md index 2f3f669e..4c950b3b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1603,10 +1603,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) - ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), - [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) - for the initial implementation) +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), + [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) +* Support for a [custom Twilio call format](config.md#phone-calls) ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) ### ntfy Android app v1.22.x (UNRELEASED) diff --git a/server/config.go b/server/config.go index 927c6b5a..c4c76bd1 100644 --- a/server/config.go +++ b/server/config.go @@ -3,6 +3,7 @@ package server import ( "io/fs" "net/netip" + "text/template" "time" "heckel.io/ntfy/v2/user" @@ -128,7 +129,7 @@ type Config struct { TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string - TwilioCallFormat string + TwilioCallFormat *template.Template MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -227,7 +228,7 @@ func NewConfig() *Config { TwilioPhoneNumber: "", TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests TwilioVerifyService: "", - TwilioCallFormat: "", + TwilioCallFormat: nil, MessageSizeLimit: DefaultMessageSizeLimit, MessageDelayMin: DefaultMessageDelayMin, MessageDelayMax: DefaultMessageDelayMax, diff --git a/server/server_twilio.go b/server/server_twilio.go index 0c5694d8..6a613d49 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -15,14 +15,13 @@ import ( "heckel.io/ntfy/v2/util" ) -const ( - // defaultTwilioCallFormat is the default TwiML format used for Twilio calls. - // It can be overridden in the server configuration's twilio-call-format field. - // - // The format uses Go template syntax with the following fields: - // {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}} - // String fields are automatically XML-escaped. - defaultTwilioCallFormat = ` +// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls. +// It can be overridden in the server configuration's twilio-call-format field. +// +// The format uses Go template syntax with the following fields: +// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}} +// String fields are automatically XML-escaped. +var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(` @@ -37,8 +36,7 @@ const ( Goodbye. -` -) +`)) // twilioCallData holds the data passed to the Twilio call format template type twilioCallData struct { @@ -83,15 +81,9 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { if u != nil { sender = u.Name } - templateStr := defaultTwilioCallFormat - if s.config.TwilioCallFormat != "" { - templateStr = s.config.TwilioCallFormat - } - tmpl, err := template.New("twiml").Parse(templateStr) - if err != nil { - logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error parsing Twilio call format template") - minc(metricCallsMadeFailure) - return + tmpl := defaultTwilioCallFormatTemplate + if s.config.TwilioCallFormat != nil { + tmpl = s.config.TwilioCallFormat } tags := make([]string, len(m.Tags)) for i, tag := range m.Tags { diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index c1418de1..9b6dcff5 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -1,14 +1,16 @@ package server import ( - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" "io" "net/http" "net/http/httptest" "sync/atomic" "testing" + "text/template" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" ) func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { @@ -222,22 +224,22 @@ func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioPhoneNumber = "+1234567890" - c.TwilioCallFormat = ` + c.TwilioCallFormat = template.Must(template.New("twiml").Parse(` - Du hast eine Nachricht von notify im Thema %s. Nachricht: + Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht: - %s + {{.Message}} Ende der Nachricht. - Diese Nachricht wurde von Benutzer %s gesendet. Sie wird drei Mal wiederholt. + Diese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt. Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app. Auf Wiederhören. -` +`)) s := newTestServer(t, c) // Add tier and user @@ -246,7 +248,7 @@ func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { MessageLimit: 10, CallLimit: 1, })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) require.Nil(t, s.userManager.ChangeTier("phil", "pro")) u, err := s.userManager.User("phil") require.Nil(t, err) From dafd130fe56f3db8023f849807b26801bc93a3e6 Mon Sep 17 00:00:00 2001 From: Maker By Night <2565361+mshafer1@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:03:29 -0600 Subject: [PATCH 096/548] Update link to ntfysh-windows client Since lucas-bortoli marked the original project as archived/abandoned, I intend to continue maintenance on my fork. Therefore, updating the integration link to point to it. --- docs/integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations.md b/docs/integrations.md index 948cd758..1ed61faf 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -90,7 +90,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [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 - [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic -- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop +- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop - [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy - [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications - [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11 From 9f3883eaf057e6347bfea288b9dc22bc5a7186d1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 14:00:08 -0500 Subject: [PATCH 097/548] Build server on Windows --- .goreleaser.yml | 14 ++++++++------ Makefile | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index f0cf08f6..3c4e9c76 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -48,13 +48,15 @@ builds: - id: ntfy_windows_amd64 binary: ntfy env: - - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [ noserver ] # don't include server files + - CGO_ENABLED=1 # required for go-sqlite3 + - CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64 + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" goos: [ windows ] - goarch: [ amd64 ] - - id: ntfy_darwin_all + goarch: [amd64 ] + - + id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 @@ -201,4 +203,4 @@ docker_manifests: - *amd64_image - *arm64v8_image - *armv7_image - - *armv6_image \ No newline at end of file + - *armv6_image diff --git a/Makefile b/Makefile index df131c7a..beac6b8f 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,7 @@ help: @echo "Build server & client (without GoReleaser):" @echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)" @echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)" + @echo " make cli-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)" @echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" @echo @echo "Build dev Docker:" @@ -201,6 +202,16 @@ cli-darwin-server: cli-deps-static-sites -ldflags \ "-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" +cli-windows-server: cli-deps-static-sites + # This is a target to build the CLI (including the server) for Windows. + # Use this for Windows development, if you really don't want to install GoReleaser ... + mkdir -p dist/ntfy_windows_server server/docs + CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \ + -o dist/ntfy_windows_server/ntfy.exe \ + -tags sqlite_omit_load_extension,osusergo,netgo \ + -ldflags \ + "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" + cli-client: cli-deps-static-sites # This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows. # Use this for development, if you really don't want to install GoReleaser ... @@ -213,7 +224,7 @@ cli-client: cli-deps-static-sites cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc -cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 +cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows cli-deps-static-sites: mkdir -p server/docs server/site @@ -228,6 +239,9 @@ cli-deps-gcc-armv6-armv7: cli-deps-gcc-arm64: which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; } +cli-deps-gcc-windows: + which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; } + cli-deps-update: go get -u go install honnef.co/go/tools/cmd/staticcheck@latest From 6d5cc6aeac8828413d47c54f46ff99b90b2fed47 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 14:43:43 -0500 Subject: [PATCH 098/548] Windows server support --- cmd/serve.go | 51 +++++-------------- cmd/serve_unix.go | 57 +++++++++++++++++++++ cmd/serve_windows.go | 104 +++++++++++++++++++++++++++++++++++++++ docs/config.md | 78 +++++++++++++++++++++++++++++ docs/install.md | 25 ++++++++-- go.mod | 2 +- server/config.go | 8 ++- server/config_unix.go | 8 +++ server/config_windows.go | 17 +++++++ 9 files changed, 304 insertions(+), 46 deletions(-) create mode 100644 cmd/serve_unix.go create mode 100644 cmd/serve_windows.go create mode 100644 server/config_unix.go create mode 100644 server/config_windows.go diff --git a/cmd/serve.go b/cmd/serve.go index 4d2803d5..dfbdc0a9 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -10,10 +10,8 @@ import ( "net" "net/netip" "net/url" - "os" - "os/signal" + "runtime" "strings" - "syscall" "text/template" "time" @@ -350,6 +348,8 @@ func execServe(c *cli.Context) error { return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128") + } else if runtime.GOOS == "windows" && listenUnix != "" { + return errors.New("listen-unix is not supported on Windows") } // Backwards compatibility @@ -503,6 +503,14 @@ func execServe(c *cli.Context) error { conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration conf.Version = c.App.Version + // Check if we should run as a Windows service + if ranAsService, err := maybeRunAsService(conf); err != nil { + log.Fatal("%s", err.Error()) + } else if ranAsService { + log.Info("Exiting.") + return nil + } + // Set up hot-reloading of config go sigHandlerConfigReload(config) @@ -517,22 +525,6 @@ func execServe(c *cli.Context) error { return nil } -func sigHandlerConfigReload(config string) { - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGHUP) - for range sigs { - log.Info("Partially hot reloading configuration ...") - inputSource, err := newYamlSourceFromFile(config, flagsServe) - if err != nil { - log.Warn("Hot reload failed: %s", err.Error()) - continue - } - if err := reloadLogLevel(inputSource); err != nil { - log.Warn("Reloading log level failed: %s", err.Error()) - } - } -} - func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { // Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32 prefix, err := netip.ParsePrefix(host) @@ -664,24 +656,3 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok return tokens, nil } -func reloadLogLevel(inputSource altsrc.InputSourceContext) error { - newLevelStr, err := inputSource.String("log-level") - if err != nil { - return fmt.Errorf("cannot load log level: %s", err.Error()) - } - overrides, err := inputSource.StringSlice("log-level-overrides") - if err != nil { - return fmt.Errorf("cannot load log level overrides (1): %s", err.Error()) - } - log.ResetLevelOverrides() - if err := applyLogLevelOverrides(overrides); err != nil { - return fmt.Errorf("cannot load log level overrides (2): %s", err.Error()) - } - log.SetLevel(log.ToLevel(newLevelStr)) - if len(overrides) > 0 { - log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides)) - } else { - log.Info("Log level is %v", strings.ToUpper(newLevelStr)) - } - return nil -} diff --git a/cmd/serve_unix.go b/cmd/serve_unix.go new file mode 100644 index 00000000..2c612338 --- /dev/null +++ b/cmd/serve_unix.go @@ -0,0 +1,57 @@ +//go:build linux || dragonfly || freebsd || netbsd || openbsd + +package cmd + +import ( + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/server" +) + +func sigHandlerConfigReload(config string) { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGHUP) + for range sigs { + log.Info("Partially hot reloading configuration ...") + inputSource, err := newYamlSourceFromFile(config, flagsServe) + if err != nil { + log.Warn("Hot reload failed: %s", err.Error()) + continue + } + if err := reloadLogLevel(inputSource); err != nil { + log.Warn("Reloading log level failed: %s", err.Error()) + } + } +} + +func reloadLogLevel(inputSource altsrc.InputSourceContext) error { + newLevelStr, err := inputSource.String("log-level") + if err != nil { + return err + } + overrides, err := inputSource.StringSlice("log-level-overrides") + if err != nil { + return err + } + log.ResetLevelOverrides() + if err := applyLogLevelOverrides(overrides); err != nil { + return err + } + log.SetLevel(log.ToLevel(newLevelStr)) + if len(overrides) > 0 { + log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides)) + } else { + log.Info("Log level is %v", newLevelStr) + } + return nil +} + +// maybeRunAsService is a no-op on Unix systems. +// Windows service mode is not available on Unix. +func maybeRunAsService(conf *server.Config) (bool, error) { + return false, nil +} diff --git a/cmd/serve_windows.go b/cmd/serve_windows.go new file mode 100644 index 00000000..c5e93c89 --- /dev/null +++ b/cmd/serve_windows.go @@ -0,0 +1,104 @@ +//go:build windows && !noserver + +package cmd + +import ( + "fmt" + "sync" + + "golang.org/x/sys/windows/svc" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/server" +) + +const serviceName = "ntfy" + +// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available. +// Windows users can restart the service to reload configuration. +func sigHandlerConfigReload(config string) { + log.Debug("Config hot-reload via SIGHUP is not supported on Windows") + // On Windows, we simply don't set up any signal handler for config reload. + // Users must restart the service/process to reload configuration. +} + +// runAsWindowsService runs the ntfy server as a Windows service +func runAsWindowsService(conf *server.Config) error { + return svc.Run(serviceName, &ntfyService{conf: conf}) +} + +// ntfyService implements the svc.Handler interface +type ntfyService struct { + conf *server.Config + server *server.Server + mu sync.Mutex +} + +// Execute is the main entry point for the Windows service +func (s *ntfyService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown + + status <- svc.Status{State: svc.StartPending} + + // Create and start the server + var err error + s.mu.Lock() + s.server, err = server.New(s.conf) + s.mu.Unlock() + if err != nil { + log.Error("Failed to create server: %s", err.Error()) + return true, 1 + } + + // Start server in a goroutine + serverErrChan := make(chan error, 1) + go func() { + serverErrChan <- s.server.Run() + }() + + status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + log.Info("Windows service started") + + for { + select { + case err := <-serverErrChan: + if err != nil { + log.Error("Server error: %s", err.Error()) + return true, 1 + } + return false, 0 + case req := <-requests: + switch req.Cmd { + case svc.Interrogate: + status <- req.CurrentStatus + case svc.Stop, svc.Shutdown: + log.Info("Windows service stopping...") + status <- svc.Status{State: svc.StopPending} + s.mu.Lock() + if s.server != nil { + s.server.Stop() + } + s.mu.Unlock() + return false, 0 + default: + log.Warn("Unexpected service control request: %d", req.Cmd) + } + } + } +} + +// maybeRunAsService checks if the process is running as a Windows service, +// and if so, runs the server as a service. Returns true if it ran as a service. +func maybeRunAsService(conf *server.Config) (bool, error) { + isService, err := svc.IsWindowsService() + if err != nil { + return false, fmt.Errorf("failed to detect Windows service mode: %w", err) + } + if !isService { + return false, nil + } + log.Info("Running as Windows service") + if err := runAsWindowsService(conf); err != nil { + return true, fmt.Errorf("failed to run as Windows service: %w", err) + } + return true, nil +} diff --git a/docs/config.md b/docs/config.md index 8a125146..584f4064 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1060,6 +1060,84 @@ or the root domain: } ``` +## Windows service +ntfy can run as a Windows service, allowing it to start automatically on boot and run in the background. +ntfy automatically detects when it is running as a Windows service and adjusts its behavior accordingly. + +### Installing the service +To install ntfy as a Windows service, open an **Administrator** command prompt or PowerShell and run: + +```cmd +sc create ntfy binPath= "C:\path\to\ntfy.exe serve" start= auto +``` + +!!! note + Make sure to replace `C:\path\to\ntfy.exe` with the actual path to your ntfy executable. + The spaces after `binPath=` and `start=` are required. + +You can also specify a config file: + +```cmd +sc create ntfy binPath= "C:\path\to\ntfy.exe serve --config C:\ProgramData\ntfy\server.yml" start= auto +``` + +### Starting and stopping +To start the service: + +```cmd +sc start ntfy +``` + +To stop the service: + +```cmd +sc stop ntfy +``` + +To check the service status: + +```cmd +sc query ntfy +``` + +### Configuring the service +The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (typically `C:\ProgramData\ntfy\server.yml`). +Create this directory and file manually if needed. + +Example minimal config: + +```yaml +base-url: "https://ntfy.example.com" +listen-http: ":80" +cache-file: "C:\\ProgramData\\ntfy\\cache.db" +auth-file: "C:\\ProgramData\\ntfy\\auth.db" +``` + +!!! warning + Use double backslashes (`\\`) for paths in YAML files on Windows, or use forward slashes (`/`). + +### Viewing logs +By default, ntfy logs to stderr. When running as a Windows service, you can configure logging to a file: + +```yaml +log-file: "C:\\ProgramData\\ntfy\\ntfy.log" +log-level: info +``` + +### Removing the service +To remove the ntfy service: + +```cmd +sc stop ntfy +sc delete ntfy +``` + +### Limitations on Windows +When running on Windows, the following features are not available: + +- **Unix socket listening**: The `listen-unix` option is not supported on Windows +- **Config hot-reload**: The SIGHUP signal for hot-reloading configuration is not available on Windows; restart the service to apply config changes + ## Firebase (FCM) !!! info Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app). diff --git a/docs/install.md b/docs/install.md index dc50e222..a437499e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -228,20 +228,39 @@ brew install ntfy ``` ## Windows -The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. +The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service. + To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip), extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. -The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). - Also available in [Scoop's](https://scoop.sh) Main repository: `scoop install ntfy` +### Running the server +To run the ntfy server directly: +``` +ntfy serve +``` + +The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`). +You may need to create the directory and config file manually. + +For information on running ntfy as a Windows service, see the [Windows service](config.md#windows-service) section in the configuration documentation. + +### Client configuration +The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). + !!! info There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. +!!! note + Some features are not available on Windows: + + - Unix socket listening (`listen-unix`) is not supported + - Config hot-reload via SIGHUP is not available; restart the service to apply config changes + ## Docker The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should be pretty straight forward to use. diff --git a/go.mod b/go.mod index ad61782e..1f7f6b4c 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 + golang.org/x/sys v0.40.0 golang.org/x/text v0.33.0 ) @@ -93,7 +94,6 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect diff --git a/server/config.go b/server/config.go index c4c76bd1..e52556f2 100644 --- a/server/config.go +++ b/server/config.go @@ -12,8 +12,6 @@ import ( // Defines default config settings (excluding limits, see below) const ( DefaultListenHTTP = ":80" - DefaultConfigFile = "/etc/ntfy/server.yml" - DefaultTemplateDir = "/etc/ntfy/templates" DefaultCacheDuration = 12 * time.Hour DefaultCacheBatchTimeout = time.Duration(0) DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) @@ -27,6 +25,12 @@ const ( DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed ) +// Platform-specific default paths (set in config_unix.go or config_windows.go) +var ( + DefaultConfigFile string + DefaultTemplateDir string +) + // Defines default Web Push settings const ( DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour diff --git a/server/config_unix.go b/server/config_unix.go new file mode 100644 index 00000000..5b9812b2 --- /dev/null +++ b/server/config_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package server + +func init() { + DefaultConfigFile = "/etc/ntfy/server.yml" + DefaultTemplateDir = "/etc/ntfy/templates" +} diff --git a/server/config_windows.go b/server/config_windows.go new file mode 100644 index 00000000..fc883c38 --- /dev/null +++ b/server/config_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package server + +import ( + "os" + "path/filepath" +) + +func init() { + programData := os.Getenv("ProgramData") + if programData == "" { + programData = `C:\ProgramData` + } + DefaultConfigFile = filepath.Join(programData, "ntfy", "server.yml") + DefaultTemplateDir = filepath.Join(programData, "ntfy", "templates") +} From dfd12528f3bd74f166be5d619fe99bfe29e12263 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 14:48:32 -0500 Subject: [PATCH 099/548] Manual nits --- cmd/serve_unix.go | 2 -- cmd/serve_windows.go | 14 +++++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/cmd/serve_unix.go b/cmd/serve_unix.go index 2c612338..f12bb85b 100644 --- a/cmd/serve_unix.go +++ b/cmd/serve_unix.go @@ -50,8 +50,6 @@ func reloadLogLevel(inputSource altsrc.InputSourceContext) error { return nil } -// maybeRunAsService is a no-op on Unix systems. -// Windows service mode is not available on Unix. func maybeRunAsService(conf *server.Config) (bool, error) { return false, nil } diff --git a/cmd/serve_windows.go b/cmd/serve_windows.go index c5e93c89..e917a079 100644 --- a/cmd/serve_windows.go +++ b/cmd/serve_windows.go @@ -17,26 +17,23 @@ const serviceName = "ntfy" // Windows users can restart the service to reload configuration. func sigHandlerConfigReload(config string) { log.Debug("Config hot-reload via SIGHUP is not supported on Windows") - // On Windows, we simply don't set up any signal handler for config reload. - // Users must restart the service/process to reload configuration. } // runAsWindowsService runs the ntfy server as a Windows service func runAsWindowsService(conf *server.Config) error { - return svc.Run(serviceName, &ntfyService{conf: conf}) + return svc.Run(serviceName, &windowsService{conf: conf}) } -// ntfyService implements the svc.Handler interface -type ntfyService struct { +// windowsService implements the svc.Handler interface +type windowsService struct { conf *server.Config server *server.Server mu sync.Mutex } // Execute is the main entry point for the Windows service -func (s *ntfyService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { +func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown - status <- svc.Status{State: svc.StartPending} // Create and start the server @@ -92,8 +89,7 @@ func maybeRunAsService(conf *server.Config) (bool, error) { isService, err := svc.IsWindowsService() if err != nil { return false, fmt.Errorf("failed to detect Windows service mode: %w", err) - } - if !isService { + } else if !isService { return false, nil } log.Info("Running as Windows service") From 353fedb93f5c6d2eb68c40d5c5ff79f14d70a6a6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 14:59:43 -0500 Subject: [PATCH 100/548] Docs, lint --- cmd/serve.go | 1 - docs/releases.md | 3 ++- server/config.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index dfbdc0a9..5acf048b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -655,4 +655,3 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok } return tokens, nil } - diff --git a/docs/releases.md b/docs/releases.md index 4c950b3b..e3e9ab50 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1605,7 +1605,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) -* Support for a [custom Twilio call format](config.md#phone-calls) ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) +* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) +* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911)) ### ntfy Android app v1.22.x (UNRELEASED) diff --git a/server/config.go b/server/config.go index e52556f2..804c0980 100644 --- a/server/config.go +++ b/server/config.go @@ -27,7 +27,7 @@ const ( // Platform-specific default paths (set in config_unix.go or config_windows.go) var ( - DefaultConfigFile string + DefaultConfigFile string DefaultTemplateDir string ) From 220372d65a49877a040c515887769b3b87d41a81 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 17:51:33 -0500 Subject: [PATCH 101/548] Move client config file logic, docs --- client/config.go | 3 ++ client/config_darwin.go | 18 ++++++++++ client/config_unix.go | 18 ++++++++++ client/config_windows.go | 14 ++++++++ cmd/subscribe.go | 54 +++++----------------------- cmd/subscribe_darwin.go | 6 ++-- cmd/subscribe_unix.go | 4 --- cmd/subscribe_windows.go | 6 ++-- docs/config.md | 78 ---------------------------------------- docs/install.md | 35 +++++++----------- 10 files changed, 79 insertions(+), 157 deletions(-) create mode 100644 client/config_darwin.go create mode 100644 client/config_unix.go create mode 100644 client/config_windows.go diff --git a/client/config.go b/client/config.go index 870c835b..444460d6 100644 --- a/client/config.go +++ b/client/config.go @@ -11,6 +11,9 @@ const ( DefaultBaseURL = "https://ntfy.sh" ) +// DefaultConfigFile is the default path to the client config file (set in config_*.go) +var DefaultConfigFile string + // Config is the config struct for a Client type Config struct { DefaultHost string `yaml:"default-host"` diff --git a/client/config_darwin.go b/client/config_darwin.go new file mode 100644 index 00000000..c2488849 --- /dev/null +++ b/client/config_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin + +package client + +import ( + "os" + "os/user" + "path/filepath" +) + +func init() { + u, err := user.Current() + if err == nil && u.Uid == "0" { + DefaultConfigFile = "/etc/ntfy/client.yml" + } else if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/client/config_unix.go b/client/config_unix.go new file mode 100644 index 00000000..273340e1 --- /dev/null +++ b/client/config_unix.go @@ -0,0 +1,18 @@ +//go:build linux || dragonfly || freebsd || netbsd || openbsd + +package client + +import ( + "os" + "os/user" + "path/filepath" +) + +func init() { + u, err := user.Current() + if err == nil && u.Uid == "0" { + DefaultConfigFile = "/etc/ntfy/client.yml" + } else if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/client/config_windows.go b/client/config_windows.go new file mode 100644 index 00000000..2ee55328 --- /dev/null +++ b/client/config_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package client + +import ( + "os" + "path/filepath" +) + +func init() { + if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/cmd/subscribe.go b/cmd/subscribe.go index 5ebf9627..84450927 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -3,28 +3,21 @@ package cmd import ( "errors" "fmt" + "os" + "os/exec" + "sort" + "strings" + "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" - "os" - "os/exec" - "os/user" - "path/filepath" - "sort" - "strings" ) func init() { commands = append(commands, cmdSubscribe) } -const ( - clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml" - clientUserConfigFileUnixRelative = "ntfy/client.yml" - clientUserConfigFileWindowsRelative = "ntfy\\client.yml" -) - var flagsSubscribe = append( append([]cli.Flag{}, flagsDefault...), &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, @@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) { if filename != "" { return client.LoadConfig(filename) } - configFile, err := defaultClientConfigFile() - if err != nil { - log.Warn("Could not determine default client config file: %s", err.Error()) - } else { - if s, _ := os.Stat(configFile); s != nil { - return client.LoadConfig(configFile) + if client.DefaultConfigFile != "" { + if s, _ := os.Stat(client.DefaultConfigFile); s != nil { + return client.LoadConfig(client.DefaultConfigFile) } - log.Debug("Config file %s not found", configFile) + log.Debug("Config file %s not found", client.DefaultConfigFile) } log.Debug("Loading default config") return client.NewConfig(), nil } -//lint:ignore U1000 Conditionally used in different builds -func defaultClientConfigFileUnix() (string, error) { - u, err := user.Current() - if err != nil { - return "", fmt.Errorf("could not determine current user: %w", err) - } - configFile := clientRootConfigFileUnixAbsolute - if u.Uid != "0" { - homeDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("could not determine user config dir: %w", err) - } - return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil - } - return configFile, nil -} - -//lint:ignore U1000 Conditionally used in different builds -func defaultClientConfigFileWindows() (string, error) { - homeDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("could not determine user config dir: %w", err) - } - return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil -} - func logMessagePrefix(m *client.Message) string { return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID) } diff --git a/cmd/subscribe_darwin.go b/cmd/subscribe_darwin.go index 487f0641..00335540 100644 --- a/cmd/subscribe_darwin.go +++ b/cmd/subscribe_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + package cmd const ( @@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.` var ( scriptLauncher = []string{"sh", "-c"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileUnix() -} diff --git a/cmd/subscribe_unix.go b/cmd/subscribe_unix.go index 3f5f526f..4c9c6039 100644 --- a/cmd/subscribe_unix.go +++ b/cmd/subscribe_unix.go @@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.` var ( scriptLauncher = []string{"sh", "-c"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileUnix() -} diff --git a/cmd/subscribe_windows.go b/cmd/subscribe_windows.go index 22c07d81..ea5f09f0 100644 --- a/cmd/subscribe_windows.go +++ b/cmd/subscribe_windows.go @@ -1,3 +1,5 @@ +//go:build windows + package cmd const ( @@ -9,7 +11,3 @@ const ( var ( scriptLauncher = []string{"cmd.exe", "/Q", "/C"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileWindows() -} diff --git a/docs/config.md b/docs/config.md index 584f4064..8a125146 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1060,84 +1060,6 @@ or the root domain: } ``` -## Windows service -ntfy can run as a Windows service, allowing it to start automatically on boot and run in the background. -ntfy automatically detects when it is running as a Windows service and adjusts its behavior accordingly. - -### Installing the service -To install ntfy as a Windows service, open an **Administrator** command prompt or PowerShell and run: - -```cmd -sc create ntfy binPath= "C:\path\to\ntfy.exe serve" start= auto -``` - -!!! note - Make sure to replace `C:\path\to\ntfy.exe` with the actual path to your ntfy executable. - The spaces after `binPath=` and `start=` are required. - -You can also specify a config file: - -```cmd -sc create ntfy binPath= "C:\path\to\ntfy.exe serve --config C:\ProgramData\ntfy\server.yml" start= auto -``` - -### Starting and stopping -To start the service: - -```cmd -sc start ntfy -``` - -To stop the service: - -```cmd -sc stop ntfy -``` - -To check the service status: - -```cmd -sc query ntfy -``` - -### Configuring the service -The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (typically `C:\ProgramData\ntfy\server.yml`). -Create this directory and file manually if needed. - -Example minimal config: - -```yaml -base-url: "https://ntfy.example.com" -listen-http: ":80" -cache-file: "C:\\ProgramData\\ntfy\\cache.db" -auth-file: "C:\\ProgramData\\ntfy\\auth.db" -``` - -!!! warning - Use double backslashes (`\\`) for paths in YAML files on Windows, or use forward slashes (`/`). - -### Viewing logs -By default, ntfy logs to stderr. When running as a Windows service, you can configure logging to a file: - -```yaml -log-file: "C:\\ProgramData\\ntfy\\ntfy.log" -log-level: info -``` - -### Removing the service -To remove the ntfy service: - -```cmd -sc stop ntfy -sc delete ntfy -``` - -### Limitations on Windows -When running on Windows, the following features are not available: - -- **Unix socket listening**: The `listen-unix` option is not supported on Windows -- **Config hot-reload**: The SIGHUP signal for hot-reloading configuration is not available on Windows; restart the service to apply config changes - ## Firebase (FCM) !!! info Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app). diff --git a/docs/install.md b/docs/install.md index a437499e..0711fdb2 100644 --- a/docs/install.md +++ b/docs/install.md @@ -229,37 +229,28 @@ brew install ntfy ## Windows 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 -To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip), +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.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` -Also available in [Scoop's](https://scoop.sh) Main repository: +Once installed, you can run the ntfy CLI commands like so: -`scoop install ntfy` - -### Running the server -To run the ntfy server directly: ``` -ntfy serve +ntfy.exe -h ``` -The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`). -You may need to create the directory and config file manually. +The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`) +for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually. -For information on running ntfy as a Windows service, see the [Windows service](config.md#windows-service) section in the configuration documentation. +To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an +elevated command prompt (adjust the path to `ntfy.exe` accordingly): -### Client configuration -The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). - -!!! info - There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a - [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. - -!!! note - Some features are not available on Windows: - - - Unix socket listening (`listen-unix`) is not supported - - Config hot-reload via SIGHUP is not available; restart the service to apply config changes +``` +sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto +sc start ntfy +``` ## Docker The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should From 64b0bd63af3a5044c1367c03c8a3ebd7a1ded09f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 18:05:36 -0500 Subject: [PATCH 102/548] Deps --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index beac6b8f..ed16cabc 100644 --- a/Makefile +++ b/Makefile @@ -107,6 +107,7 @@ build-deps-ubuntu: curl \ gcc-aarch64-linux-gnu \ gcc-arm-linux-gnueabi \ + gcc-mingw-w64-x86-64 \ python3 \ python3-venv \ jq From 8deb2df88d39b1510022b2b19a9fe84d6d4f1d92 Mon Sep 17 00:00:00 2001 From: "Philipp C. Heckel" Date: Sat, 17 Jan 2026 20:26:37 -0500 Subject: [PATCH 103/548] Update Windows support details in releases.md --- docs/releases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index e3e9ab50..f325a5c7 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1606,7 +1606,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) * Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) -* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911)) +* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104), [#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911)) ### ntfy Android app v1.22.x (UNRELEASED) From cc9f9c0d243226e298f370c7ff392ff9568ead40 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 20:36:15 -0500 Subject: [PATCH 104/548] Update checker --- server/config.go | 87 ++++++++++++++++++++++++++++++ server/server.go | 12 +++++ server/types.go | 6 +++ web/public/config.js | 1 + web/public/static/langs/en.json | 2 + web/src/app/VersionChecker.js | 93 +++++++++++++++++++++++++++++++++ web/src/components/App.jsx | 52 ++++++++++++++++-- web/src/components/hooks.js | 15 ++++++ 8 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 web/src/app/VersionChecker.js diff --git a/server/config.go b/server/config.go index 804c0980..5136e82e 100644 --- a/server/config.go +++ b/server/config.go @@ -1,6 +1,9 @@ package server import ( + "crypto/sha256" + "encoding/json" + "fmt" "io/fs" "net/netip" "text/template" @@ -275,3 +278,87 @@ func NewConfig() *Config { WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, } } + +// configHashData is a subset of Config fields used for computing the config hash. +// It excludes sensitive fields (keys, passwords, tokens) and runtime-only fields. +type configHashData struct { + BaseURL string + ListenHTTP string + ListenHTTPS string + ListenUnix string + CacheDuration time.Duration + AttachmentTotalSizeLimit int64 + AttachmentFileSizeLimit int64 + AttachmentExpiryDuration time.Duration + KeepaliveInterval time.Duration + ManagerInterval time.Duration + DisallowedTopics []string + WebRoot string + MessageDelayMin time.Duration + MessageDelayMax time.Duration + MessageSizeLimit int + TotalTopicLimit int + VisitorSubscriptionLimit int + VisitorAttachmentTotalSizeLimit int64 + VisitorAttachmentDailyBandwidthLimit int64 + VisitorRequestLimitBurst int + VisitorRequestLimitReplenish time.Duration + VisitorMessageDailyLimit int + VisitorEmailLimitBurst int + VisitorEmailLimitReplenish time.Duration + EnableSignup bool + EnableLogin bool + RequireLogin bool + EnableReservations bool + EnableMetrics bool + EnablePayments bool + EnableCalls bool + EnableEmails bool + EnableWebPush bool + BillingContact string + Version string +} + +// Hash computes a SHA-256 hash of the configuration. This is used to detect +// configuration changes for the web app version check feature. +func (c *Config) Hash() string { + data := configHashData{ + BaseURL: c.BaseURL, + ListenHTTP: c.ListenHTTP, + ListenHTTPS: c.ListenHTTPS, + ListenUnix: c.ListenUnix, + CacheDuration: c.CacheDuration, + AttachmentTotalSizeLimit: c.AttachmentTotalSizeLimit, + AttachmentFileSizeLimit: c.AttachmentFileSizeLimit, + AttachmentExpiryDuration: c.AttachmentExpiryDuration, + KeepaliveInterval: c.KeepaliveInterval, + ManagerInterval: c.ManagerInterval, + DisallowedTopics: c.DisallowedTopics, + WebRoot: c.WebRoot, + MessageDelayMin: c.MessageDelayMin, + MessageDelayMax: c.MessageDelayMax, + MessageSizeLimit: c.MessageSizeLimit, + TotalTopicLimit: c.TotalTopicLimit, + VisitorSubscriptionLimit: c.VisitorSubscriptionLimit, + VisitorAttachmentTotalSizeLimit: c.VisitorAttachmentTotalSizeLimit, + VisitorAttachmentDailyBandwidthLimit: c.VisitorAttachmentDailyBandwidthLimit, + VisitorRequestLimitBurst: c.VisitorRequestLimitBurst, + VisitorRequestLimitReplenish: c.VisitorRequestLimitReplenish, + VisitorMessageDailyLimit: c.VisitorMessageDailyLimit, + VisitorEmailLimitBurst: c.VisitorEmailLimitBurst, + VisitorEmailLimitReplenish: c.VisitorEmailLimitReplenish, + EnableSignup: c.EnableSignup, + EnableLogin: c.EnableLogin, + RequireLogin: c.RequireLogin, + EnableReservations: c.EnableReservations, + EnableMetrics: c.EnableMetrics, + EnablePayments: c.StripeSecretKey != "", + EnableCalls: c.TwilioAccount != "", + EnableEmails: c.SMTPSenderFrom != "", + EnableWebPush: c.WebPushPublicKey != "", + BillingContact: c.BillingContact, + Version: c.Version, + } + b, _ := json.Marshal(data) + return fmt.Sprintf("%x", sha256.Sum256(b)) +} diff --git a/server/server.go b/server/server.go index 3bd53ea6..f2c30fb0 100644 --- a/server/server.go +++ b/server/server.go @@ -90,6 +90,7 @@ var ( matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" apiHealthPath = "/v1/health" + apiVersionPath = "/v1/version" apiStatsPath = "/v1/stats" apiWebPushPath = "/v1/webpush" apiTiersPath = "/v1/tiers" @@ -460,6 +461,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { return s.handleHealth(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath { + return s.handleVersion(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { @@ -600,6 +603,14 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor return s.writeJSON(w, response) } +func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + response := &apiVersionResponse{ + Version: s.config.Version, + ConfigHash: s.config.Hash(), + } + return s.writeJSON(w, response) +} + func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { response := &apiConfigResponse{ BaseURL: "", // Will translate to window.location.origin @@ -615,6 +626,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi BillingContact: s.config.BillingContact, WebPushPublicKey: s.config.WebPushPublicKey, DisallowedTopics: s.config.DisallowedTopics, + ConfigHash: s.config.Hash(), } b, err := json.MarshalIndent(response, "", " ") if err != nil { diff --git a/server/types.go b/server/types.go index 6464222f..a8ffc2bc 100644 --- a/server/types.go +++ b/server/types.go @@ -317,6 +317,11 @@ type apiHealthResponse struct { Healthy bool `json:"healthy"` } +type apiVersionResponse struct { + Version string `json:"version"` + ConfigHash string `json:"config_hash"` +} + type apiStatsResponse struct { Messages int64 `json:"messages"` MessagesRate float64 `json:"messages_rate"` // Average number of messages per second @@ -482,6 +487,7 @@ type apiConfigResponse struct { BillingContact string `json:"billing_contact"` WebPushPublicKey string `json:"web_push_public_key"` DisallowedTopics []string `json:"disallowed_topics"` + ConfigHash string `json:"config_hash"` } type apiAccountBillingPrices struct { diff --git a/web/public/config.js b/web/public/config.js index fcc567aa..62f49ed4 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -19,4 +19,5 @@ var config = { billing_contact: "", web_push_public_key: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], + config_hash: "dev", // Placeholder for development; actual value is generated server-side }; diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index b0d3c545..bfbcf610 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -4,6 +4,8 @@ "common_add": "Add", "common_back": "Back", "common_copy_to_clipboard": "Copy to clipboard", + "common_refresh": "Refresh", + "version_update_available": "New ntfy version available. Please refresh the page.", "signup_title": "Create a ntfy account", "signup_form_username": "Username", "signup_form_password": "Password", diff --git a/web/src/app/VersionChecker.js b/web/src/app/VersionChecker.js new file mode 100644 index 00000000..ae0272b4 --- /dev/null +++ b/web/src/app/VersionChecker.js @@ -0,0 +1,93 @@ +/** + * VersionChecker polls the /v1/version endpoint to detect server restarts + * or configuration changes, prompting users to refresh the page. + */ + +const CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes + +class VersionChecker { + constructor() { + this.initialConfigHash = null; + this.listener = null; + this.intervalId = null; + } + + /** + * Starts the version checker worker. It stores the initial config hash + * from the config.js and polls the server every 5 minutes. + */ + startWorker() { + // Store initial config hash from the config loaded at page load + this.initialConfigHash = window.config?.config_hash || null; + + if (!this.initialConfigHash) { + console.log("[VersionChecker] No initial config_hash found, version checking disabled"); + return; + } + + console.log("[VersionChecker] Starting version checker with initial hash:", this.initialConfigHash); + + // Start polling + this.intervalId = setInterval(() => this.checkVersion(), CHECK_INTERVAL); + } + + /** + * Stops the version checker worker. + */ + stopWorker() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + console.log("[VersionChecker] Stopped version checker"); + } + + /** + * Registers a listener that will be called when a version change is detected. + * @param {function} listener - Callback function that receives no arguments + */ + registerListener(listener) { + this.listener = listener; + } + + /** + * Resets the listener. + */ + resetListener() { + this.listener = null; + } + + /** + * Fetches the current version from the server and compares it with the initial config hash. + */ + async checkVersion() { + if (!this.initialConfigHash) { + return; + } + + try { + const response = await fetch(`${window.config?.base_url || ""}/v1/version`); + if (!response.ok) { + console.log("[VersionChecker] Failed to fetch version:", response.status); + return; + } + + const data = await response.json(); + const currentHash = data.config_hash; + + console.log("[VersionChecker] Checked version, initial:", this.initialConfigHash, "current:", currentHash); + + if (currentHash && currentHash !== this.initialConfigHash) { + console.log("[VersionChecker] Config hash changed, notifying listener"); + if (this.listener) { + this.listener(); + } + } + } catch (error) { + console.log("[VersionChecker] Error checking version:", error); + } + } +} + +const versionChecker = new VersionChecker(); +export default versionChecker; diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 9a2c3e66..e6157b86 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -1,6 +1,18 @@ import * as React from "react"; -import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react"; -import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material"; +import { createContext, Suspense, useContext, useEffect, useState, useMemo, useCallback } from "react"; +import { + Box, + Toolbar, + CssBaseline, + Backdrop, + CircularProgress, + useMediaQuery, + ThemeProvider, + createTheme, + Snackbar, + Button, + Alert, +} from "@mui/material"; import { useLiveQuery } from "dexie-react-hooks"; import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -14,7 +26,13 @@ import userManager from "../app/UserManager"; import { expandUrl, getKebabCaseLangStr } from "../app/utils"; import ErrorBoundary from "./ErrorBoundary"; import routes from "./routes"; -import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks"; +import { + useAccountListener, + useBackgroundProcesses, + useConnectionListeners, + useWebPushTopics, + useVersionChangeListener, +} from "./hooks"; import PublishDialog from "./PublishDialog"; import Messaging from "./Messaging"; import Login from "./Login"; @@ -100,10 +118,12 @@ const updateTitle = (newNotificationsCount) => { }; const Layout = () => { + const { t } = useTranslation(); const params = useParams(); const { account, setAccount } = useContext(AccountContext); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); + const [versionChanged, setVersionChanged] = useState(false); const users = useLiveQuery(() => userManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all()); const webPushTopics = useWebPushTopics(); @@ -115,9 +135,18 @@ const Layout = () => { (config.base_url === s.baseUrl && params.topic === s.topic) ); + const handleVersionChange = useCallback(() => { + setVersionChanged(true); + }, []); + + const handleRefresh = useCallback(() => { + window.location.reload(); + }, []); + useConnectionListeners(account, subscriptions, users, webPushTopics); useAccountListener(setAccount); useBackgroundProcesses(); + useVersionChangeListener(handleVersionChange); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); return ( @@ -140,6 +169,23 @@ const Layout = () => { /> + + + {t("common_refresh")} + + } + > + {t("version_update_available")} + + ); }; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 9dadd551..a9268dd2 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -9,6 +9,7 @@ import poller from "../app/Poller"; import pruner from "../app/Pruner"; import session from "../app/Session"; import accountApi from "../app/AccountApi"; +import versionChecker from "../app/VersionChecker"; import { UnauthorizedError } from "../app/errors"; import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; @@ -292,12 +293,14 @@ const startWorkers = () => { poller.startWorker(); pruner.startWorker(); accountApi.startWorker(); + versionChecker.startWorker(); }; const stopWorkers = () => { poller.stopWorker(); pruner.stopWorker(); accountApi.stopWorker(); + versionChecker.stopWorker(); }; export const useBackgroundProcesses = () => { @@ -323,3 +326,15 @@ export const useAccountListener = (setAccount) => { }; }, []); }; + +/** + * Hook to detect version/config changes and call the provided callback when a change is detected. + */ +export const useVersionChangeListener = (onVersionChange) => { + useEffect(() => { + versionChecker.registerListener(onVersionChange); + return () => { + versionChecker.resetListener(); + }; + }, [onVersionChange]); +}; From 5a1aa68ead963f72728e750df50f53a85f50ac18 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 09:44:21 -0500 Subject: [PATCH 105/548] Refine --- server/config.go | 83 ++----------------------------- web/public/static/langs/en.json | 3 +- web/src/app/VersionChecker.js | 2 +- web/src/components/App.jsx | 52 ++----------------- web/src/components/Navigation.jsx | 29 ++++++++++- 5 files changed, 38 insertions(+), 131 deletions(-) diff --git a/server/config.go b/server/config.go index 5136e82e..8a111ed4 100644 --- a/server/config.go +++ b/server/config.go @@ -182,7 +182,7 @@ type Config struct { WebPushStartupQueries string WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration - Version string // injected by App + Version string // Injected by App } // NewConfig instantiates a default new server config @@ -279,86 +279,13 @@ func NewConfig() *Config { } } -// configHashData is a subset of Config fields used for computing the config hash. -// It excludes sensitive fields (keys, passwords, tokens) and runtime-only fields. -type configHashData struct { - BaseURL string - ListenHTTP string - ListenHTTPS string - ListenUnix string - CacheDuration time.Duration - AttachmentTotalSizeLimit int64 - AttachmentFileSizeLimit int64 - AttachmentExpiryDuration time.Duration - KeepaliveInterval time.Duration - ManagerInterval time.Duration - DisallowedTopics []string - WebRoot string - MessageDelayMin time.Duration - MessageDelayMax time.Duration - MessageSizeLimit int - TotalTopicLimit int - VisitorSubscriptionLimit int - VisitorAttachmentTotalSizeLimit int64 - VisitorAttachmentDailyBandwidthLimit int64 - VisitorRequestLimitBurst int - VisitorRequestLimitReplenish time.Duration - VisitorMessageDailyLimit int - VisitorEmailLimitBurst int - VisitorEmailLimitReplenish time.Duration - EnableSignup bool - EnableLogin bool - RequireLogin bool - EnableReservations bool - EnableMetrics bool - EnablePayments bool - EnableCalls bool - EnableEmails bool - EnableWebPush bool - BillingContact string - Version string -} - // Hash computes a SHA-256 hash of the configuration. This is used to detect // configuration changes for the web app version check feature. func (c *Config) Hash() string { - data := configHashData{ - BaseURL: c.BaseURL, - ListenHTTP: c.ListenHTTP, - ListenHTTPS: c.ListenHTTPS, - ListenUnix: c.ListenUnix, - CacheDuration: c.CacheDuration, - AttachmentTotalSizeLimit: c.AttachmentTotalSizeLimit, - AttachmentFileSizeLimit: c.AttachmentFileSizeLimit, - AttachmentExpiryDuration: c.AttachmentExpiryDuration, - KeepaliveInterval: c.KeepaliveInterval, - ManagerInterval: c.ManagerInterval, - DisallowedTopics: c.DisallowedTopics, - WebRoot: c.WebRoot, - MessageDelayMin: c.MessageDelayMin, - MessageDelayMax: c.MessageDelayMax, - MessageSizeLimit: c.MessageSizeLimit, - TotalTopicLimit: c.TotalTopicLimit, - VisitorSubscriptionLimit: c.VisitorSubscriptionLimit, - VisitorAttachmentTotalSizeLimit: c.VisitorAttachmentTotalSizeLimit, - VisitorAttachmentDailyBandwidthLimit: c.VisitorAttachmentDailyBandwidthLimit, - VisitorRequestLimitBurst: c.VisitorRequestLimitBurst, - VisitorRequestLimitReplenish: c.VisitorRequestLimitReplenish, - VisitorMessageDailyLimit: c.VisitorMessageDailyLimit, - VisitorEmailLimitBurst: c.VisitorEmailLimitBurst, - VisitorEmailLimitReplenish: c.VisitorEmailLimitReplenish, - EnableSignup: c.EnableSignup, - EnableLogin: c.EnableLogin, - RequireLogin: c.RequireLogin, - EnableReservations: c.EnableReservations, - EnableMetrics: c.EnableMetrics, - EnablePayments: c.StripeSecretKey != "", - EnableCalls: c.TwilioAccount != "", - EnableEmails: c.SMTPSenderFrom != "", - EnableWebPush: c.WebPushPublicKey != "", - BillingContact: c.BillingContact, - Version: c.Version, + b, err := json.Marshal(c) + if err != nil { + fmt.Println(err) } - b, _ := json.Marshal(data) + fmt.Println(string(b)) return fmt.Sprintf("%x", sha256.Sum256(b)) } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index bfbcf610..15e78976 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -5,7 +5,8 @@ "common_back": "Back", "common_copy_to_clipboard": "Copy to clipboard", "common_refresh": "Refresh", - "version_update_available": "New ntfy version available. Please refresh the page.", + "version_update_available_title": "New version available", + "version_update_available_description": "The ntfy server has been updated. Please refresh the page.", "signup_title": "Create a ntfy account", "signup_form_username": "Username", "signup_form_password": "Password", diff --git a/web/src/app/VersionChecker.js b/web/src/app/VersionChecker.js index ae0272b4..07459cb2 100644 --- a/web/src/app/VersionChecker.js +++ b/web/src/app/VersionChecker.js @@ -3,7 +3,7 @@ * or configuration changes, prompting users to refresh the page. */ -const CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes +const CHECK_INTERVAL = 30 * 1000; // 5 * 60 * 1000; // 5 minutes class VersionChecker { constructor() { diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index e6157b86..9a2c3e66 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -1,18 +1,6 @@ import * as React from "react"; -import { createContext, Suspense, useContext, useEffect, useState, useMemo, useCallback } from "react"; -import { - Box, - Toolbar, - CssBaseline, - Backdrop, - CircularProgress, - useMediaQuery, - ThemeProvider, - createTheme, - Snackbar, - Button, - Alert, -} from "@mui/material"; +import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react"; +import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material"; import { useLiveQuery } from "dexie-react-hooks"; import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -26,13 +14,7 @@ import userManager from "../app/UserManager"; import { expandUrl, getKebabCaseLangStr } from "../app/utils"; import ErrorBoundary from "./ErrorBoundary"; import routes from "./routes"; -import { - useAccountListener, - useBackgroundProcesses, - useConnectionListeners, - useWebPushTopics, - useVersionChangeListener, -} from "./hooks"; +import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks"; import PublishDialog from "./PublishDialog"; import Messaging from "./Messaging"; import Login from "./Login"; @@ -118,12 +100,10 @@ const updateTitle = (newNotificationsCount) => { }; const Layout = () => { - const { t } = useTranslation(); const params = useParams(); const { account, setAccount } = useContext(AccountContext); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); - const [versionChanged, setVersionChanged] = useState(false); const users = useLiveQuery(() => userManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all()); const webPushTopics = useWebPushTopics(); @@ -135,18 +115,9 @@ const Layout = () => { (config.base_url === s.baseUrl && params.topic === s.topic) ); - const handleVersionChange = useCallback(() => { - setVersionChanged(true); - }, []); - - const handleRefresh = useCallback(() => { - window.location.reload(); - }, []); - useConnectionListeners(account, subscriptions, users, webPushTopics); useAccountListener(setAccount); useBackgroundProcesses(); - useVersionChangeListener(handleVersionChange); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); return ( @@ -169,23 +140,6 @@ const Layout = () => { /> - - - {t("common_refresh")} - - } - > - {t("version_update_available")} - - ); }; diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 7e30931a..75b587ea 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -21,7 +21,7 @@ import { useTheme, } from "@mui/material"; import * as React from "react"; -import { useContext, useState } from "react"; +import { useCallback, useContext, useState } from "react"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; import Person from "@mui/icons-material/Person"; import SettingsIcon from "@mui/icons-material/Settings"; @@ -44,7 +44,7 @@ import UpgradeDialog from "./UpgradeDialog"; import { AccountContext } from "./App"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { SubscriptionPopup } from "./SubscriptionPopup"; -import { useNotificationPermissionListener } from "./hooks"; +import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks"; const navWidth = 280; @@ -91,6 +91,13 @@ const NavList = (props) => { const { account } = useContext(AccountContext); const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); + const [versionChanged, setVersionChanged] = useState(false); + + const handleVersionChange = useCallback(() => { + setVersionChanged(true); + }, []); + + useVersionChangeListener(handleVersionChange); const handleSubscribeReset = () => { setSubscribeDialogOpen(false); @@ -119,6 +126,7 @@ const NavList = (props) => { const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser const alertVisible = + versionChanged || showNotificationPermissionRequired || showNotificationPermissionDenied || showNotificationIOSInstallRequired || @@ -129,6 +137,7 @@ const NavList = (props) => { <> + {versionChanged && } {showNotificationPermissionRequired && } {showNotificationPermissionDenied && } {showNotificationBrowserNotSupportedBox && } @@ -425,4 +434,20 @@ const NotificationContextNotSupportedAlert = () => { ); }; +const VersionUpdateBanner = () => { + const { t } = useTranslation(); + const handleRefresh = () => { + window.location.reload(); + }; + return ( + + {t("version_update_available_title")} + {t("version_update_available_description")} + + + ); +}; + export default Navigation; From 856f15095846c0f06275e019b8545276658e2968 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 10:46:15 -0500 Subject: [PATCH 106/548] Better --- cmd/serve.go | 25 +++++++++++++++- main.go | 19 ++++++++++-- server/config.go | 29 ++++++++++++------ server/server.go | 42 +++++++++++++------------- server/server_twilio.go | 6 ++-- server/types.go | 5 ---- web/src/app/Pruner.js | 6 +++- web/src/app/VersionChecker.js | 50 ++++++++++--------------------- web/src/components/Navigation.jsx | 26 ++++++++-------- 9 files changed, 117 insertions(+), 91 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 5acf048b..0208055b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -128,6 +128,12 @@ Examples: ntfy serve --listen-http :8080 # Starts server with alternate port`, } +// App metadata fields used to pass from +const ( + MetadataKeyCommit = "commit" + MetadataKeyDate = "date" +) + func execServe(c *cli.Context) error { if c.NArg() > 0 { return errors.New("no arguments expected, see 'ntfy serve --help' for help") @@ -501,7 +507,9 @@ func execServe(c *cli.Context) error { conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration - conf.Version = c.App.Version + conf.BuildVersion = c.App.Version + conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate) + conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit) // Check if we should run as a Windows service if ranAsService, err := maybeRunAsService(conf); err != nil { @@ -655,3 +663,18 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok } return tokens, nil } + +func maybeFromMetadata(m map[string]any, key string) string { + if m == nil { + return "" + } + v, exists := m[key] + if !exists { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return s +} diff --git a/main.go b/main.go index 4e01a0d6..b9bef369 100644 --- a/main.go +++ b/main.go @@ -2,12 +2,14 @@ package main import ( "fmt" - "github.com/urfave/cli/v2" - "heckel.io/ntfy/v2/cmd" "os" "runtime" + + "github.com/urfave/cli/v2" + "heckel.io/ntfy/v2/cmd" ) +// These variables are set during build time using -ldflags var ( version = "dev" commit = "unknown" @@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org). ntfy %s (%s), runtime %s, built at %s Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2 -`, version, commit[:7], runtime.Version(), date) +`, version, maybeShortCommit(commit), runtime.Version(), date) app := cmd.New() app.Version = version + app.Metadata = map[string]any{ + cmd.MetadataKeyDate: date, + cmd.MetadataKeyCommit: commit, + } if err := app.Run(os.Args); err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } } + +func maybeShortCommit(commit string) string { + if len(commit) > 7 { + return commit[:7] + } + return commit +} diff --git a/server/config.go b/server/config.go index 8a111ed4..26b00b8b 100644 --- a/server/config.go +++ b/server/config.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "net/netip" + "reflect" "text/template" "time" @@ -182,7 +183,9 @@ type Config struct { WebPushStartupQueries string WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration - Version string // Injected by App + BuildVersion string // Injected by App + BuildDate string // Injected by App + BuildCommit string // Injected by App } // NewConfig instantiates a default new server config @@ -269,23 +272,31 @@ func NewConfig() *Config { EnableReservations: false, RequireLogin: false, AccessControlAllowOrigin: "*", - Version: "", WebPushPrivateKey: "", WebPushPublicKey: "", WebPushFile: "", WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, + BuildVersion: "", + BuildDate: "", } } -// Hash computes a SHA-256 hash of the configuration. This is used to detect -// configuration changes for the web app version check feature. +// Hash computes an SHA-256 hash of the configuration. This is used to detect +// configuration changes for the web app version check feature. It uses reflection +// to include all JSON-serializable fields automatically. func (c *Config) Hash() string { - b, err := json.Marshal(c) - if err != nil { - fmt.Println(err) + v := reflect.ValueOf(*c) + t := v.Type() + var result string + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldName := t.Field(i).Name + // Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix) + if b, err := json.Marshal(field.Interface()); err == nil { + result += fmt.Sprintf("%s:%s|", fieldName, string(b)) + } } - fmt.Println(string(b)) - return fmt.Sprintf("%x", sha256.Sum256(b)) + return fmt.Sprintf("%x", sha256.Sum256([]byte(result))) } diff --git a/server/server.go b/server/server.go index f2c30fb0..d3c72029 100644 --- a/server/server.go +++ b/server/server.go @@ -90,7 +90,7 @@ var ( matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" apiHealthPath = "/v1/health" - apiVersionPath = "/v1/version" + apiConfigPath = "/v1/config" apiStatsPath = "/v1/stats" apiWebPushPath = "/v1/webpush" apiTiersPath = "/v1/tiers" @@ -278,9 +278,9 @@ func (s *Server) Run() error { if s.config.ProfileListenHTTP != "" { listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP) } - log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String()) + log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String()) if log.IsFile() { - fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version) + fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion) fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File()) } mux := http.NewServeMux() @@ -461,8 +461,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { return s.handleHealth(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath { - return s.handleVersion(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath { + return s.handleConfig(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { @@ -603,16 +603,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor return s.writeJSON(w, response) } -func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request, _ *visitor) error { - response := &apiVersionResponse{ - Version: s.config.Version, - ConfigHash: s.config.Hash(), - } - return s.writeJSON(w, response) +func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + w.Header().Set("Cache-Control", "no-cache") + return s.writeJSON(w, s.configResponse()) } func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { - response := &apiConfigResponse{ + b, err := json.MarshalIndent(s.configResponse(), "", " ") + if err != nil { + return err + } + w.Header().Set("Content-Type", "text/javascript") + w.Header().Set("Cache-Control", "no-cache") + _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b))) + return err +} + +func (s *Server) configResponse() *apiConfigResponse { + return &apiConfigResponse{ BaseURL: "", // Will translate to window.location.origin AppRoot: s.config.WebRoot, EnableLogin: s.config.EnableLogin, @@ -628,14 +636,6 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi DisallowedTopics: s.config.DisallowedTopics, ConfigHash: s.config.Hash(), } - b, err := json.MarshalIndent(response, "", " ") - if err != nil { - return err - } - w.Header().Set("Content-Type", "text/javascript") - w.Header().Set("Cache-Control", "no-cache") - _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b))) - return err } // handleWebManifest serves the web app manifest for the progressive web app (PWA) @@ -1003,7 +1003,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { logvm(v, m).Err(err).Warn("Unable to publish poll request") return } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion) req.Header.Set("X-Poll-ID", m.ID) if s.config.UpstreamAccessToken != "" { req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken)) diff --git a/server/server_twilio.go b/server/server_twilio.go index 6a613d49..c1761613 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -125,7 +125,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) { if err != nil { return "", err } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) @@ -149,7 +149,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha if err != nil { return err } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) @@ -175,7 +175,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber if err != nil { return err } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) diff --git a/server/types.go b/server/types.go index a8ffc2bc..fae44e5b 100644 --- a/server/types.go +++ b/server/types.go @@ -317,11 +317,6 @@ type apiHealthResponse struct { Healthy bool `json:"healthy"` } -type apiVersionResponse struct { - Version string `json:"version"` - ConfigHash string `json:"config_hash"` -} - type apiStatsResponse struct { Messages int64 `json:"messages"` MessagesRate float64 `json:"messages_rate"` // Average number of messages per second diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index f9568a33..f08fb770 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -19,7 +19,11 @@ class Pruner { } stopWorker() { - clearTimeout(this.timer); + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + console.log("[VersionChecker] Stopped pruner checker"); } async prune() { diff --git a/web/src/app/VersionChecker.js b/web/src/app/VersionChecker.js index 07459cb2..53746126 100644 --- a/web/src/app/VersionChecker.js +++ b/web/src/app/VersionChecker.js @@ -1,5 +1,5 @@ /** - * VersionChecker polls the /v1/version endpoint to detect server restarts + * VersionChecker polls the /v1/config endpoint to detect new server versions * or configuration changes, prompting users to refresh the page. */ @@ -9,7 +9,8 @@ class VersionChecker { constructor() { this.initialConfigHash = null; this.listener = null; - this.intervalId = null; + console.log("XXXXXXxxxx set listener null"); + this.timer = null; } /** @@ -18,73 +19,52 @@ class VersionChecker { */ startWorker() { // Store initial config hash from the config loaded at page load - this.initialConfigHash = window.config?.config_hash || null; - - if (!this.initialConfigHash) { - console.log("[VersionChecker] No initial config_hash found, version checking disabled"); - return; - } - - console.log("[VersionChecker] Starting version checker with initial hash:", this.initialConfigHash); - - // Start polling - this.intervalId = setInterval(() => this.checkVersion(), CHECK_INTERVAL); + this.initialConfigHash = window.config?.config_hash || ""; + console.log("[VersionChecker] Starting version checker"); + this.timer = setInterval(() => this.checkVersion(), CHECK_INTERVAL); } - /** - * Stops the version checker worker. - */ stopWorker() { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; } console.log("[VersionChecker] Stopped version checker"); } - /** - * Registers a listener that will be called when a version change is detected. - * @param {function} listener - Callback function that receives no arguments - */ registerListener(listener) { this.listener = listener; } - /** - * Resets the listener. - */ resetListener() { this.listener = null; } - /** - * Fetches the current version from the server and compares it with the initial config hash. - */ async checkVersion() { if (!this.initialConfigHash) { return; } try { - const response = await fetch(`${window.config?.base_url || ""}/v1/version`); + const response = await fetch(`${window.config?.base_url || ""}/v1/config`); if (!response.ok) { - console.log("[VersionChecker] Failed to fetch version:", response.status); + console.log("[VersionChecker] Failed to fetch config:", response.status); return; } const data = await response.json(); const currentHash = data.config_hash; - console.log("[VersionChecker] Checked version, initial:", this.initialConfigHash, "current:", currentHash); - if (currentHash && currentHash !== this.initialConfigHash) { - console.log("[VersionChecker] Config hash changed, notifying listener"); + console.log("[VersionChecker] Version or config changed, showing banner"); if (this.listener) { this.listener(); } + } else { + console.log("[VersionChecker] No version change detected"); } } catch (error) { - console.log("[VersionChecker] Error checking version:", error); + console.log("[VersionChecker] Error checking config:", error); } } } diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 75b587ea..89381cb3 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -1,27 +1,27 @@ import { - Drawer, - ListItemButton, - ListItemIcon, - ListItemText, - Toolbar, - Divider, - List, Alert, AlertTitle, Badge, + Box, + Button, CircularProgress, + Divider, + Drawer, + IconButton, Link, + List, + ListItemButton, + ListItemIcon, + ListItemText, ListSubheader, Portal, + Toolbar, Tooltip, Typography, - Box, - IconButton, - Button, useTheme, } from "@mui/material"; import * as React from "react"; -import { useCallback, useContext, useState } from "react"; +import { useContext, useState } from "react"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; import Person from "@mui/icons-material/Person"; import SettingsIcon from "@mui/icons-material/Settings"; @@ -93,9 +93,9 @@ const NavList = (props) => { const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); const [versionChanged, setVersionChanged] = useState(false); - const handleVersionChange = useCallback(() => { + const handleVersionChange = () => { setVersionChanged(true); - }, []); + }; useVersionChangeListener(handleVersionChange); From 6886ca24b1afbc930c1dabbcc1c1b19efddfc4a9 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 10:51:36 -0500 Subject: [PATCH 107/548] Self-review --- web/src/app/Pruner.js | 2 +- web/src/app/VersionChecker.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index f08fb770..074370b2 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -23,7 +23,7 @@ class Pruner { clearTimeout(this.timer); this.timer = null; } - console.log("[VersionChecker] Stopped pruner checker"); + console.log("[Pruner] Stopped worker"); } async prune() { diff --git a/web/src/app/VersionChecker.js b/web/src/app/VersionChecker.js index 53746126..8f7c2c30 100644 --- a/web/src/app/VersionChecker.js +++ b/web/src/app/VersionChecker.js @@ -3,13 +3,12 @@ * or configuration changes, prompting users to refresh the page. */ -const CHECK_INTERVAL = 30 * 1000; // 5 * 60 * 1000; // 5 minutes +const intervalMillis = 5 * 60 * 1000; // 5 minutes class VersionChecker { constructor() { this.initialConfigHash = null; this.listener = null; - console.log("XXXXXXxxxx set listener null"); this.timer = null; } @@ -21,7 +20,7 @@ class VersionChecker { // Store initial config hash from the config loaded at page load this.initialConfigHash = window.config?.config_hash || ""; console.log("[VersionChecker] Starting version checker"); - this.timer = setInterval(() => this.checkVersion(), CHECK_INTERVAL); + this.timer = setInterval(() => this.checkVersion(), intervalMillis); } stopWorker() { From 886be722bc48f41dbf385771f867b9fd095bb9a0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 10:52:27 -0500 Subject: [PATCH 108/548] Release notes --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index e3e9ab50..587b3f54 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1607,6 +1607,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) * Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) * `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911)) +* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554)) ### ntfy Android app v1.22.x (UNRELEASED) From eddf654b9685cad56bf0e652578bf41b064d834f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 10:56:11 -0500 Subject: [PATCH 109/548] Last fixes --- server/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/config.go b/server/config.go index 26b00b8b..278b6aed 100644 --- a/server/config.go +++ b/server/config.go @@ -280,6 +280,7 @@ func NewConfig() *Config { WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, BuildVersion: "", BuildDate: "", + BuildCommit: "", } } From 279c164bf5d22cec1a8b079a57c504444927e3ef Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 11:13:56 -0500 Subject: [PATCH 110/548] Fix build --- cmd/app.go | 11 +++++++++-- cmd/serve.go | 5 ----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index d88a9d58..c62af2fb 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -3,11 +3,12 @@ package cmd import ( "fmt" + "os" + "regexp" + "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/v2/log" - "os" - "regexp" ) const ( @@ -15,6 +16,12 @@ const ( categoryServer = "Server commands" ) +// Build metadata keys for app.Metadata +const ( + MetadataKeyCommit = "commit" + MetadataKeyDate = "date" +) + var commands = make([]*cli.Command, 0) var flagsDefault = []cli.Flag{ diff --git a/cmd/serve.go b/cmd/serve.go index 0208055b..d4c8a0aa 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -128,11 +128,6 @@ Examples: ntfy serve --listen-http :8080 # Starts server with alternate port`, } -// App metadata fields used to pass from -const ( - MetadataKeyCommit = "commit" - MetadataKeyDate = "date" -) func execServe(c *cli.Context) error { if c.NArg() > 0 { From 86157fc7f6e5a5e2bad3fb92e532d03eaa038e46 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 11:14:07 -0500 Subject: [PATCH 111/548] Lint --- cmd/serve.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/serve.go b/cmd/serve.go index d4c8a0aa..b451a118 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -128,7 +128,6 @@ Examples: ntfy serve --listen-http :8080 # Starts server with alternate port`, } - func execServe(c *cli.Context) error { if c.NArg() > 0 { return errors.New("no arguments expected, see 'ntfy serve --help' for help") From c23d201186813bfb376321af5ec9683ef5ada1d4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 15:50:40 -0500 Subject: [PATCH 112/548] Updated/cancel scheduled messages --- server/message_cache.go | 9 ++++ server/message_cache_test.go | 69 +++++++++++++++++++++++++ server/server.go | 8 +++ server/server_test.go | 98 ++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+) diff --git a/server/message_cache.go b/server/message_cache.go index 342f9687..df6b4e54 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -73,6 +73,7 @@ const ( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` + deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` @@ -607,6 +608,14 @@ func (c *messageCache) DeleteMessages(ids ...string) error { return tx.Commit() } +// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID +func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.db.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID) + return err +} + func (c *messageCache) ExpireMessages(topics ...string) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 1e285605..25f2a91b 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -703,6 +703,75 @@ func testSender(t *testing.T, c *messageCache) { require.Equal(t, messages[1].Sender, netip.Addr{}) } +func TestSqliteCache_DeleteScheduledBySequenceID(t *testing.T) { + testDeleteScheduledBySequenceID(t, newSqliteTestCache(t)) +} + +func TestMemCache_DeleteScheduledBySequenceID(t *testing.T) { + testDeleteScheduledBySequenceID(t, newMemTestCache(t)) +} + +func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) { + // Create a scheduled (unpublished) message + scheduledMsg := newDefaultMessage("mytopic", "scheduled message") + scheduledMsg.ID = "scheduled1" + scheduledMsg.SequenceID = "seq123" + scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled + require.Nil(t, c.AddMessage(scheduledMsg)) + + // Create a published message with different sequence ID + publishedMsg := newDefaultMessage("mytopic", "published message") + publishedMsg.ID = "published1" + publishedMsg.SequenceID = "seq456" + publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published + require.Nil(t, c.AddMessage(publishedMsg)) + + // Create a scheduled message in a different topic + otherTopicMsg := newDefaultMessage("othertopic", "other scheduled") + otherTopicMsg.ID = "other1" + otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg + otherTopicMsg.Time = time.Now().Add(time.Hour).Unix() + require.Nil(t, c.AddMessage(otherTopicMsg)) + + // Verify all messages exist (including scheduled) + messages, err := c.Messages("mytopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + + messages, err = c.Messages("othertopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + + // Delete scheduled message by sequence ID + err = c.DeleteScheduledBySequenceID("mytopic", "seq123") + require.Nil(t, err) + + // Verify scheduled message is deleted + messages, err = c.Messages("mytopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "published message", messages[0].Message) + + // Verify other topic's message still exists (topic-scoped deletion) + messages, err = c.Messages("othertopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "other scheduled", messages[0].Message) + + // Deleting non-existent sequence ID should not error + err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent") + require.Nil(t, err) + + // Deleting published message should not affect it (only deletes unpublished) + err = c.DeleteScheduledBySequenceID("mytopic", "seq456") + require.Nil(t, err) + + messages, err = c.Messages("mytopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "published message", messages[0].Message) +} + func checkSchemaVersion(t *testing.T, db *sql.DB) { rows, err := db.Query(`SELECT version FROM schemaVersion`) require.Nil(t, err) diff --git a/server/server.go b/server/server.go index d3c72029..5f775ee1 100644 --- a/server/server.go +++ b/server/server.go @@ -863,6 +863,10 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later") } if cache { + // Delete any existing scheduled message with the same sequence ID + if err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID); err != nil { + return nil, err + } logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache") if err := s.messageCache.AddMessage(m); err != nil { return nil, err @@ -958,6 +962,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * if s.config.WebPushPublicKey != "" { go s.publishToWebPushEndpoints(v, m) } + // Delete any existing scheduled message with the same sequence ID + if err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID); err != nil { + return err + } // Add to message cache if err := s.messageCache.AddMessage(m); err != nil { return err diff --git a/server/server_test.go b/server/server_test.go index 530d9458..3af029b4 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3495,6 +3495,104 @@ func TestServer_ClearMessage_WithFirebase(t *testing.T) { require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"]) } +func TestServer_UpdateScheduledMessage(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a scheduled message (future delivery) + response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.Equal(t, "sched-seq", msg1.SequenceID) + require.Equal(t, "original scheduled message", msg1.Message) + + // Verify scheduled message exists + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "original scheduled message", messages[0].Message) + + // Update the scheduled message (same sequence ID, new content) + response = request(t, s, "PUT", "/mytopic/sched-seq?delay=2h", "updated scheduled message", nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, "sched-seq", msg2.SequenceID) + require.Equal(t, "updated scheduled message", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) + + // Verify only the updated message exists (old scheduled was deleted) + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "updated scheduled message", messages[0].Message) + require.Equal(t, msg2.ID, messages[0].ID) +} + +func TestServer_DeleteScheduledMessage(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a scheduled message (future delivery) + response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-sched-seq", msg.SequenceID) + + // Verify scheduled message exists + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "scheduled message to delete", messages[0].Message) + + // Delete the scheduled message + response = request(t, s, "DELETE", "/mytopic/delete-sched-seq", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID) + require.Equal(t, "message_delete", deleteMsg.Event) + + // Verify scheduled message was deleted, only delete event remains + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "message_delete", messages[0].Event) + require.Equal(t, "delete-sched-seq", messages[0].SequenceID) +} + +func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish scheduled messages with same sequence ID in different topics + response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil) + require.Equal(t, 200, response.Code) + + response = request(t, s, "PUT", "/topic2/shared-seq?delay=1h", "topic2 scheduled", nil) + require.Equal(t, 200, response.Code) + + // Update scheduled message in topic1 only + response = request(t, s, "PUT", "/topic1/shared-seq?delay=2h", "topic1 updated", nil) + require.Equal(t, 200, response.Code) + + // Verify topic1 has only the updated message + response = request(t, s, "GET", "/topic1/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "topic1 updated", messages[0].Message) + + // Verify topic2 still has its original scheduled message (not affected) + response = request(t, s, "GET", "/topic2/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "topic2 scheduled", messages[0].Message) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" From ac9cfbfaf426c30a2f7914a04d9e28571b46c807 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 16:04:42 -0500 Subject: [PATCH 113/548] Delete attachments --- server/message_cache.go | 49 ++++++++++++++++++++++++------ server/message_cache_test.go | 14 +++++---- server/server.go | 18 +++++++++-- server/server_test.go | 58 ++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 16 deletions(-) diff --git a/server/message_cache.go b/server/message_cache.go index df6b4e54..84083aee 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -72,11 +72,12 @@ const ( INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` - deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` - deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` - updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` - selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics - selectMessagesByIDQuery = ` + deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` + selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` + deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` + updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` + selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics + selectMessagesByIDQuery = ` 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, content_type, encoding FROM messages WHERE mid = ? @@ -608,12 +609,42 @@ func (c *messageCache) DeleteMessages(ids ...string) error { return tx.Commit() } -// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID -func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) error { +// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID. +// It returns the message IDs of the deleted messages, which can be used to clean up attachment files. +func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) { c.mu.Lock() defer c.mu.Unlock() - _, err := c.db.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID) - return err + tx, err := c.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + // First, get the message IDs of scheduled messages to be deleted + rows, err := tx.Query(selectScheduledMessageIDsBySeqIDQuery, topic, sequenceID) + if err != nil { + return nil, err + } + defer rows.Close() + ids := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + rows.Close() // Close rows before executing delete in same transaction + // Then delete the messages + if _, err := tx.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return ids, nil } func (c *messageCache) ExpireMessages(topics ...string) error { diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 25f2a91b..672f91b0 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -742,9 +742,11 @@ func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) { require.Nil(t, err) require.Equal(t, 1, len(messages)) - // Delete scheduled message by sequence ID - err = c.DeleteScheduledBySequenceID("mytopic", "seq123") + // Delete scheduled message by sequence ID and verify returned IDs + deletedIDs, err := c.DeleteScheduledBySequenceID("mytopic", "seq123") require.Nil(t, err) + require.Equal(t, 1, len(deletedIDs)) + require.Equal(t, "scheduled1", deletedIDs[0]) // Verify scheduled message is deleted messages, err = c.Messages("mytopic", sinceAllMessages, true) @@ -758,13 +760,15 @@ func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) { require.Equal(t, 1, len(messages)) require.Equal(t, "other scheduled", messages[0].Message) - // Deleting non-existent sequence ID should not error - err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent") + // Deleting non-existent sequence ID should return empty list + deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent") require.Nil(t, err) + require.Empty(t, deletedIDs) // Deleting published message should not affect it (only deletes unpublished) - err = c.DeleteScheduledBySequenceID("mytopic", "seq456") + deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "seq456") require.Nil(t, err) + require.Empty(t, deletedIDs) messages, err = c.Messages("mytopic", sinceAllMessages, true) require.Nil(t, err) diff --git a/server/server.go b/server/server.go index 5f775ee1..7274e686 100644 --- a/server/server.go +++ b/server/server.go @@ -864,9 +864,16 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e } if cache { // Delete any existing scheduled message with the same sequence ID - if err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID); err != nil { + deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID) + if err != nil { return nil, err } + // Delete attachment files for deleted scheduled messages + if s.fileCache != nil && len(deletedIDs) > 0 { + if err := s.fileCache.Remove(deletedIDs...); err != nil { + logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages") + } + } logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache") if err := s.messageCache.AddMessage(m); err != nil { return nil, err @@ -963,9 +970,16 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * go s.publishToWebPushEndpoints(v, m) } // Delete any existing scheduled message with the same sequence ID - if err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID); err != nil { + deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID) + if err != nil { return err } + // Delete attachment files for deleted scheduled messages + if s.fileCache != nil && len(deletedIDs) > 0 { + if err := s.fileCache.Remove(deletedIDs...); err != nil { + logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages") + } + } // Add to message cache if err := s.messageCache.AddMessage(m); err != nil { return err diff --git a/server/server_test.go b/server/server_test.go index 3af029b4..0b125638 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3593,6 +3593,64 @@ func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) { require.Equal(t, "topic2 scheduled", messages[0].Message) } +func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a scheduled message with an attachment + content := util.RandomString(5000) // > 4096 to trigger attachment + response := request(t, s, "PUT", "/mytopic/attach-seq?delay=1h", content, nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.Equal(t, "attach-seq", msg1.SequenceID) + require.NotNil(t, msg1.Attachment) + + // Verify attachment file exists + attachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID) + require.FileExists(t, attachmentFile1) + + // Update the scheduled message with a new attachment + newContent := util.RandomString(5000) + response = request(t, s, "PUT", "/mytopic/attach-seq?delay=2h", newContent, nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, "attach-seq", msg2.SequenceID) + require.NotEqual(t, msg1.ID, msg2.ID) + + // Verify old attachment file was deleted + require.NoFileExists(t, attachmentFile1) + + // Verify new attachment file exists + attachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID) + require.FileExists(t, attachmentFile2) +} + +func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Publish a scheduled message with an attachment + content := util.RandomString(5000) // > 4096 to trigger attachment + response := request(t, s, "PUT", "/mytopic/delete-attach-seq?delay=1h", content, nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-attach-seq", msg.SequenceID) + require.NotNil(t, msg.Attachment) + + // Verify attachment file exists + attachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID) + require.FileExists(t, attachmentFile) + + // Delete the scheduled message + response = request(t, s, "DELETE", "/mytopic/delete-attach-seq", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "message_delete", deleteMsg.Event) + + // Verify attachment file was deleted + require.NoFileExists(t, attachmentFile) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" From 9ecf21c65abd97938f87e17f33c4b199190e9e7a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 16:16:04 -0500 Subject: [PATCH 114/548] Docs --- docs/publish.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/docs/publish.md b/docs/publish.md index 3363063a..86a01eed 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -833,6 +833,81 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim +### Updating scheduled messages +You can update or replace a scheduled message before it is delivered by publishing a new message with the same +[sequence ID](#updating-deleting-notifications). When you do this, the **original scheduled message is deleted** +from the server and replaced with the new one. This is different from [updating notifications](#updating-notifications) +after delivery, where both messages are kept in the cache. + +This is particularly useful for implementing a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) — +a mechanism that triggers an alert if it's not periodically reset. For example, you could schedule a message to be +delivered in 5 minutes, but continuously update it every minute to push the delivery time further into the future. +If your script or system stops running, the message will eventually be delivered as an alert. + +Here's an example of a dead man's switch that sends an alert if the script stops running for more than 5 minutes: + +=== "Command line (curl)" + ```bash + # Dead man's switch: keeps pushing a scheduled message into the future + # If this script stops, the alert will be delivered after 5 minutes + while true; do + curl -H "In: 5m" -d "Warning: Server heartbeat stopped!" \ + ntfy.sh/mytopic/heartbeat-check + sleep 60 # Update every minute + done + ``` + +=== "Python" + ``` python + import requests + import time + + # Dead man's switch: keeps pushing a scheduled message into the future + # If this script stops, the alert will be delivered after 5 minutes + while True: + requests.post( + "https://ntfy.sh/mytopic/heartbeat-check", + data="Warning: Server heartbeat stopped!", + headers={"In": "5m"} + ) + time.sleep(60) # Update every minute + ``` + +### Canceling scheduled messages +You can cancel a scheduled message before it is delivered by sending a DELETE request to the +`//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the +scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers. + +=== "Command line (curl)" + ```bash + # Schedule a reminder for 2 hours from now + curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder + + # Changed your mind? Cancel the scheduled message + curl -X DELETE ntfy.sh/mytopic/break-reminder + ``` + +=== "HTTP" + ``` http + DELETE /mytopic/break-reminder HTTP/1.1 + Host: ntfy.sh + ``` + +=== "Python" + ``` python + import requests + + # Schedule a reminder + requests.post( + "https://ntfy.sh/mytopic/break-reminder", + data="Take a break!", + headers={"In": "2h"} + ) + + # Cancel it + requests.delete("https://ntfy.sh/mytopic/break-reminder") + ``` + ## Webhooks (publish via GET) _Supported on:_ :material-android: :material-apple: :material-firefox: From b8e01fde33391d8ed03f6bdf296f7442b7628a50 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 16:22:06 -0500 Subject: [PATCH 115/548] Docs --- docs/publish.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 86a01eed..02004403 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -857,11 +857,75 @@ Here's an example of a dead man's switch that sends an alert if the script stops done ``` +=== "ntfy CLI" + ```bash + # Dead man's switch: keeps pushing a scheduled message into the future + # If this script stops, the alert will be delivered after 5 minutes + while true; do + ntfy publish \ + --in="5m" \ + --sequence-id="heartbeat-check" \ + mytopic "Warning: Server heartbeat stopped!" + sleep 60 # Update every minute + done + ``` + +=== "HTTP" + ``` http + POST /mytopic/heartbeat-check HTTP/1.1 + Host: ntfy.sh + In: 5m + + Warning: Server heartbeat stopped! + ``` + +=== "JavaScript" + ``` javascript + // Dead man's switch: keeps pushing a scheduled message into the future + // If this script stops, the alert will be delivered after 5 minutes + setInterval(() => { + fetch('https://ntfy.sh/mytopic/heartbeat-check', { + method: 'POST', + body: 'Warning: Server heartbeat stopped!', + headers: { 'In': '5m' } + }) + }, 60000) // Update every minute + ``` + +=== "Go" + ``` go + // Dead man's switch: keeps pushing a scheduled message into the future + // If this script stops, the alert will be delivered after 5 minutes + for { + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic/heartbeat-check", + strings.NewReader("Warning: Server heartbeat stopped!")) + req.Header.Set("In", "5m") + http.DefaultClient.Do(req) + time.Sleep(60 * time.Second) // Update every minute + } + ``` + +=== "PowerShell" + ``` powershell + # Dead man's switch: keeps pushing a scheduled message into the future + # If this script stops, the alert will be delivered after 5 minutes + while ($true) { + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic/heartbeat-check" + Headers = @{ In = "5m" } + Body = "Warning: Server heartbeat stopped!" + } + Invoke-RestMethod @Request + Start-Sleep -Seconds 60 # Update every minute + } + ``` + === "Python" ``` python import requests import time - + # Dead man's switch: keeps pushing a scheduled message into the future # If this script stops, the alert will be delivered after 5 minutes while True: @@ -873,6 +937,22 @@ Here's an example of a dead man's switch that sends an alert if the script stops time.sleep(60) # Update every minute ``` +=== "PHP" + ``` php-inline + // Dead man's switch: keeps pushing a scheduled message into the future + // If this script stops, the alert will be delivered after 5 minutes + while (true) { + file_get_contents('https://ntfy.sh/mytopic/heartbeat-check', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\nIn: 5m", + 'content' => 'Warning: Server heartbeat stopped!' + ] + ])); + sleep(60); // Update every minute + } + ``` + ### Canceling scheduled messages You can cancel a scheduled message before it is delivered by sending a DELETE request to the `//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the @@ -887,27 +967,97 @@ scheduled message from the server so it will never be delivered, and emit a `mes curl -X DELETE ntfy.sh/mytopic/break-reminder ``` +=== "ntfy CLI" + ```bash + # Schedule a reminder for 2 hours from now + ntfy publish --in="2h" mytopic/break-reminder "Take a break!" + + # Changed your mind? Cancel the scheduled message + # (ntfy CLI does not support DELETE, use curl instead) + curl -X DELETE ntfy.sh/mytopic/break-reminder + ``` + === "HTTP" ``` http DELETE /mytopic/break-reminder HTTP/1.1 Host: ntfy.sh ``` +=== "JavaScript" + ``` javascript + // Schedule a reminder for 2 hours from now + await fetch('https://ntfy.sh/mytopic/break-reminder', { + method: 'POST', + body: 'Take a break!', + headers: { 'In': '2h' } + }); + + // Changed your mind? Cancel the scheduled message + await fetch('https://ntfy.sh/mytopic/break-reminder', { + method: 'DELETE' + }); + ``` + +=== "Go" + ``` go + // Schedule a reminder for 2 hours from now + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic/break-reminder", + strings.NewReader("Take a break!")) + req.Header.Set("In", "2h") + http.DefaultClient.Do(req) + + // Changed your mind? Cancel the scheduled message + req, _ = http.NewRequest("DELETE", "https://ntfy.sh/mytopic/break-reminder", nil) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Schedule a reminder for 2 hours from now + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic/break-reminder" + Headers = @{ In = "2h" } + Body = "Take a break!" + } + Invoke-RestMethod @Request + + # Changed your mind? Cancel the scheduled message + Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/break-reminder" + ``` + === "Python" ``` python import requests - - # Schedule a reminder + + # Schedule a reminder for 2 hours from now requests.post( "https://ntfy.sh/mytopic/break-reminder", data="Take a break!", headers={"In": "2h"} ) - - # Cancel it + + # Changed your mind? Cancel the scheduled message requests.delete("https://ntfy.sh/mytopic/break-reminder") ``` +=== "PHP" + ``` php-inline + // Schedule a reminder for 2 hours from now + file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\nIn: 2h", + 'content' => 'Take a break!' + ] + ])); + + // Changed your mind? Cancel the scheduled message + file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([ + 'http' => ['method' => 'DELETE'] + ])); + ``` + ## Webhooks (publish via GET) _Supported on:_ :material-android: :material-apple: :material-firefox: From 2739d8a3258eb50b096653dd67270254d874fbcd Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 19:09:14 -0500 Subject: [PATCH 116/548] Re-organize docs --- docs/publish.md | 3085 +++++++++++++++++++++++----------------------- docs/releases.md | 8 +- 2 files changed, 1549 insertions(+), 1544 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 02004403..cec5db27 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -727,61 +727,65 @@ Here's what that looks like in the web app:
    Markdown formatting in the web app
    -## Scheduled delivery +## Click action _Supported on:_ :material-android: :material-apple: :material-firefox: -You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself -reminders or even to execute commands at a later date (if your subscriber acts on messages). +You can define which URL to open when a notification is clicked. This may be useful if your notification is related +to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open +the web browser (or the app) and open the website. -Usage is pretty straight forward. You can set the delivery time using the `X-Delay` header (or any of its aliases: `Delay`, -`X-At`, `At`, `X-In` or `In`), either by specifying a Unix timestamp (e.g. `1639194738`), a duration (e.g. `30m`, -`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`, -[and more](https://github.com/olebedev/when)). +To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its alias `Click`). +If you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled +by another app, the responsible app may open. -As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured -with the `message-delay-limit` option). +Examples: -For the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours -after they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled -to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Also note that naturally, -[turning off server-side caching](#message-caching) is not possible in combination with this feature. +* `http://` or `https://` will open your browser (or an app if it registered for a URL) +* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com` +* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA` +* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats` +* `twitter://` links will open Twitter, e.g. `twitter://user?screen_name=..` +* ... + +Here's an example that will open Reddit when the notification is clicked: === "Command line (curl)" ``` - curl -H "At: tomorrow, 10am" -d "Good morning" ntfy.sh/hello - curl -H "In: 30min" -d "It's 30 minutes later now" ntfy.sh/reminder - curl -H "Delay: 1639194738" -d "Unix timestamps are awesome" ntfy.sh/itsaunixsystem + curl \ + -d "New messages on Reddit" \ + -H "Click: https://www.reddit.com/message/messages" \ + ntfy.sh/reddit_alerts ``` === "ntfy CLI" ``` ntfy publish \ - --at="tomorrow, 10am" \ - hello "Good morning" + --click="https://www.reddit.com/message/messages" \ + reddit_alerts "New messages on Reddit" ``` === "HTTP" ``` http - POST /hello HTTP/1.1 + POST /reddit_alerts HTTP/1.1 Host: ntfy.sh - At: tomorrow, 10am + Click: https://www.reddit.com/message/messages - Good morning + New messages on Reddit ``` === "JavaScript" ``` javascript - fetch('https://ntfy.sh/hello', { + fetch('https://ntfy.sh/reddit_alerts', { method: 'POST', - body: 'Good morning', - headers: { 'At': 'tomorrow, 10am' } + body: 'New messages on Reddit', + headers: { 'Click': 'https://www.reddit.com/message/messages' } }) ``` === "Go" ``` go - req, _ := http.NewRequest("POST", "https://ntfy.sh/hello", strings.NewReader("Good morning")) - req.Header.Set("At", "tomorrow, 10am") + req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit")) + req.Header.Set("Click", "https://www.reddit.com/message/messages") http.DefaultClient.Do(req) ``` @@ -789,1227 +793,207 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al ``` powershell $Request = @{ Method = "POST" - URI = "https://ntfy.sh/hello" - Headers = @{ - At = "tomorrow, 10am" - } - Body = "Good morning" + URI = "https://ntfy.sh/reddit_alerts" + Headers = @{ Click="https://www.reddit.com/message/messages" } + Body = "New messages on Reddit" } Invoke-RestMethod @Request ``` - + === "Python" ``` python - requests.post("https://ntfy.sh/hello", - data="Good morning", - headers={ "At": "tomorrow, 10am" }) + requests.post("https://ntfy.sh/reddit_alerts", + data="New messages on Reddit", + headers={ "Click": "https://www.reddit.com/message/messages" }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/backups', false, stream_context_create([ + file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: text/plain\r\n" . - "At: tomorrow, 10am", - 'content' => 'Good morning' + "Click: https://www.reddit.com/message/messages", + 'content' => 'New messages on Reddit' ] ])); ``` -Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**): +## Icons +_Supported on:_ :material-android: - - -
    - - - - - - - -
    Delay/At/In headerMessage will be delivered atExplanation
    30m12/10/2021, 9:30am30 minutes from now
    2 hours12/10/2021, 11:30am2 hours from now
    1 day12/11/2021, 9am24 hours from now
    10am12/10/2021, 10amToday at 10am (same day, because it's only 9am)
    8am12/11/2021, 8amTomorrow at 8am (because it's 9am already)
    163915200012/10/2021, 11am (EST) Today at 11am (EST)
    -
    +You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query +parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download +the icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are +cached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**. -### Updating scheduled messages -You can update or replace a scheduled message before it is delivered by publishing a new message with the same -[sequence ID](#updating-deleting-notifications). When you do this, the **original scheduled message is deleted** -from the server and replaced with the new one. This is different from [updating notifications](#updating-notifications) -after delivery, where both messages are kept in the cache. - -This is particularly useful for implementing a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) — -a mechanism that triggers an alert if it's not periodically reset. For example, you could schedule a message to be -delivered in 5 minutes, but continuously update it every minute to push the delivery time further into the future. -If your script or system stops running, the message will eventually be delivered as an alert. - -Here's an example of a dead man's switch that sends an alert if the script stops running for more than 5 minutes: - -=== "Command line (curl)" - ```bash - # Dead man's switch: keeps pushing a scheduled message into the future - # If this script stops, the alert will be delivered after 5 minutes - while true; do - curl -H "In: 5m" -d "Warning: Server heartbeat stopped!" \ - ntfy.sh/mytopic/heartbeat-check - sleep 60 # Update every minute - done - ``` - -=== "ntfy CLI" - ```bash - # Dead man's switch: keeps pushing a scheduled message into the future - # If this script stops, the alert will be delivered after 5 minutes - while true; do - ntfy publish \ - --in="5m" \ - --sequence-id="heartbeat-check" \ - mytopic "Warning: Server heartbeat stopped!" - sleep 60 # Update every minute - done - ``` - -=== "HTTP" - ``` http - POST /mytopic/heartbeat-check HTTP/1.1 - Host: ntfy.sh - In: 5m - - Warning: Server heartbeat stopped! - ``` - -=== "JavaScript" - ``` javascript - // Dead man's switch: keeps pushing a scheduled message into the future - // If this script stops, the alert will be delivered after 5 minutes - setInterval(() => { - fetch('https://ntfy.sh/mytopic/heartbeat-check', { - method: 'POST', - body: 'Warning: Server heartbeat stopped!', - headers: { 'In': '5m' } - }) - }, 60000) // Update every minute - ``` - -=== "Go" - ``` go - // Dead man's switch: keeps pushing a scheduled message into the future - // If this script stops, the alert will be delivered after 5 minutes - for { - req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic/heartbeat-check", - strings.NewReader("Warning: Server heartbeat stopped!")) - req.Header.Set("In", "5m") - http.DefaultClient.Do(req) - time.Sleep(60 * time.Second) // Update every minute - } - ``` - -=== "PowerShell" - ``` powershell - # Dead man's switch: keeps pushing a scheduled message into the future - # If this script stops, the alert will be delivered after 5 minutes - while ($true) { - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mytopic/heartbeat-check" - Headers = @{ In = "5m" } - Body = "Warning: Server heartbeat stopped!" - } - Invoke-RestMethod @Request - Start-Sleep -Seconds 60 # Update every minute - } - ``` - -=== "Python" - ``` python - import requests - import time - - # Dead man's switch: keeps pushing a scheduled message into the future - # If this script stops, the alert will be delivered after 5 minutes - while True: - requests.post( - "https://ntfy.sh/mytopic/heartbeat-check", - data="Warning: Server heartbeat stopped!", - headers={"In": "5m"} - ) - time.sleep(60) # Update every minute - ``` - -=== "PHP" - ``` php-inline - // Dead man's switch: keeps pushing a scheduled message into the future - // If this script stops, the alert will be delivered after 5 minutes - while (true) { - file_get_contents('https://ntfy.sh/mytopic/heartbeat-check', false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: text/plain\r\nIn: 5m", - 'content' => 'Warning: Server heartbeat stopped!' - ] - ])); - sleep(60); // Update every minute - } - ``` - -### Canceling scheduled messages -You can cancel a scheduled message before it is delivered by sending a DELETE request to the -`//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the -scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers. - -=== "Command line (curl)" - ```bash - # Schedule a reminder for 2 hours from now - curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder - - # Changed your mind? Cancel the scheduled message - curl -X DELETE ntfy.sh/mytopic/break-reminder - ``` - -=== "ntfy CLI" - ```bash - # Schedule a reminder for 2 hours from now - ntfy publish --in="2h" mytopic/break-reminder "Take a break!" - - # Changed your mind? Cancel the scheduled message - # (ntfy CLI does not support DELETE, use curl instead) - curl -X DELETE ntfy.sh/mytopic/break-reminder - ``` - -=== "HTTP" - ``` http - DELETE /mytopic/break-reminder HTTP/1.1 - Host: ntfy.sh - ``` - -=== "JavaScript" - ``` javascript - // Schedule a reminder for 2 hours from now - await fetch('https://ntfy.sh/mytopic/break-reminder', { - method: 'POST', - body: 'Take a break!', - headers: { 'In': '2h' } - }); - - // Changed your mind? Cancel the scheduled message - await fetch('https://ntfy.sh/mytopic/break-reminder', { - method: 'DELETE' - }); - ``` - -=== "Go" - ``` go - // Schedule a reminder for 2 hours from now - req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic/break-reminder", - strings.NewReader("Take a break!")) - req.Header.Set("In", "2h") - http.DefaultClient.Do(req) - - // Changed your mind? Cancel the scheduled message - req, _ = http.NewRequest("DELETE", "https://ntfy.sh/mytopic/break-reminder", nil) - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - # Schedule a reminder for 2 hours from now - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mytopic/break-reminder" - Headers = @{ In = "2h" } - Body = "Take a break!" - } - Invoke-RestMethod @Request - - # Changed your mind? Cancel the scheduled message - Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/break-reminder" - ``` - -=== "Python" - ``` python - import requests - - # Schedule a reminder for 2 hours from now - requests.post( - "https://ntfy.sh/mytopic/break-reminder", - data="Take a break!", - headers={"In": "2h"} - ) - - # Changed your mind? Cancel the scheduled message - requests.delete("https://ntfy.sh/mytopic/break-reminder") - ``` - -=== "PHP" - ``` php-inline - // Schedule a reminder for 2 hours from now - file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: text/plain\r\nIn: 2h", - 'content' => 'Take a break!' - ] - ])); - - // Changed your mind? Cancel the scheduled message - file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([ - 'http' => ['method' => 'DELETE'] - ])); - ``` - -## Webhooks (publish via GET) -_Supported on:_ :material-android: :material-apple: :material-firefox: - -In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use -a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support. - -To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without -any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are -also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all -[supported parameters and headers](#list-of-all-parameters) for details. - -For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message -(aka trigger the webhook): +Here's an example showing how to include an icon: === "Command line (curl)" ``` - curl ntfy.sh/mywebhook/trigger - ``` - -=== "ntfy CLI" - ``` - ntfy trigger mywebhook - ``` - -=== "HTTP" - ``` http - GET /mywebhook/trigger HTTP/1.1 - Host: ntfy.sh - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh/mywebhook/trigger') - ``` - -=== "Go" - ``` go - http.Get("https://ntfy.sh/mywebhook/trigger") - ``` - -=== "PowerShell" - ``` powershell - Invoke-RestMethod "ntfy.sh/mywebhook/trigger" - ``` - -=== "Python" - ``` python - requests.get("https://ntfy.sh/mywebhook/trigger") - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/mywebhook/trigger'); - ``` - -To add a custom message, simply append the `message=` URL parameter. And of course you can set the -[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well. -For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters). - -Here's an example with a custom message, tags and a priority: - -=== "Command line (curl)" - ``` - curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + curl \ + -H "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ + -H "Title: Kodi: Resuming Playback" \ + -H "Tags: arrow_forward" \ + -d "The Wire, S01E01" \ + ntfy.sh/tvshows ``` === "ntfy CLI" ``` ntfy publish \ - -p 5 --tags=warning,skull \ - mywebhook "Webhook triggered" + --icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ + --title="Kodi: Resuming Playback" \ + --tags="arrow_forward" \ + tvshows \ + "The Wire, S01E01" ``` === "HTTP" ``` http - GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1 + POST /tvshows HTTP/1.1 Host: ntfy.sh + Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png + Tags: arrow_forward + Title: Kodi: Resuming Playback + + The Wire, S01E01 ``` === "JavaScript" ``` javascript - fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull') + fetch('https://ntfy.sh/tvshows', { + method: 'POST', + headers: { + 'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png', + 'Title': 'Kodi: Resuming Playback', + 'Tags': 'arrow_forward' + }, + body: "The Wire, S01E01" + }) ``` === "Go" ``` go - http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + req, _ := http.NewRequest("POST", "https://ntfy.sh/tvshows", strings.NewReader("The Wire, S01E01")) + req.Header.Set("Icon", "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png") + req.Header.Set("Tags", "arrow_forward") + req.Header.Set("Title", "Kodi: Resuming Playback") + http.DefaultClient.Do(req) ``` === "PowerShell" ``` powershell - Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/tvshows" + Headers = @{ + Title = "Kodi: Resuming Playback" + Tags = "arrow_forward" + Icon = "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" + } + Body = "The Wire, S01E01" + } + Invoke-RestMethod @Request ``` === "Python" ``` python - requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + requests.post("https://ntfy.sh/tvshows", + data="The Wire, S01E01", + headers={ + "Title": "Kodi: Resuming Playback", + "Tags": "arrow_forward", + "Icon": "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" + }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); + file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'header' => + "Content-Type: text/plain\r\n" . // Does not matter + "Title: Kodi: Resuming Playback\r\n" . + "Tags: arrow_forward\r\n" . + "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png", + ], + 'content' => "The Wire, S01E01" + ])); ``` -## Updating + deleting notifications +Here's an example of how it will look on Android: + +
    + ![file attachment](static/img/android-screenshot-icon.png){ width=500 } +
    Custom icon from an external URL
    +
    + +## Attachments _Supported on:_ :material-android: :material-firefox: -!!! info - **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. +You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded +onto your phone (depending on size and setting automatically), and can be used from the Downloads folder. -You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios -like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. +There are two different ways to send attachments: -* [Updating notifications](#updating-notifications) will alter the content of an existing notification. -* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer. -* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported). +* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3` +* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk` -Here's an example of a download progress notification being updated over time on Android: +### Attach local file +To **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater +than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically +detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files +as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases +`Filename`, `File` or `f`). -
    - - -
    +By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor). +Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app +to auto-download it. Please also check out the [other limits below](#limitations). -To facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence, -using a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the -same sequence ID and the clients will perform the appropriate action on the existing notification. - -Existing ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates -the update, clear, or delete action. This append-only behavior ensures that message history remains intact. - -### Updating notifications -To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous -notification with the new one. You can either: - -1. **Use the message ID**: First publish like normal to `POST /` without a sequence ID, then use the returned message `id` as the sequence ID for updates -2. **Use a custom sequence ID**: Publish directly to `POST //` with your own identifier, or use `POST /` with the - `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`) - -If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned -message `id` to update it. Here's an example: - -=== "Command line (curl)" - ```bash - # First, publish a message and capture the message ID - curl -d "Downloading file..." ntfy.sh/mytopic - # Returns: {"id":"xE73Iyuabi","time":1673542291,...} - - # Then use the message ID to update it (via URL path) - curl -d "Download 50% ..." ntfy.sh/mytopic/xE73Iyuabi - - # Or update using the X-Sequence-ID header - curl -H "X-Sequence-ID: xE73Iyuabi" -d "Download complete" ntfy.sh/mytopic - ``` - -=== "ntfy CLI" - ```bash - # First, publish a message and capture the message ID - ntfy pub mytopic "Downloading file..." - # Returns: {"id":"xE73Iyuabi","time":1673542291,...} - - # Then use the message ID to update it - ntfy pub --sequence-id=xE73Iyuabi mytopic "Download 50% ..." - - # Update again with the same sequence ID - ntfy pub -S xE73Iyuabi mytopic "Download complete" - ``` - -=== "HTTP" - ``` http - # First, publish a message and capture the message ID - POST /mytopic HTTP/1.1 - Host: ntfy.sh - - Downloading file... - - # Returns: {"id":"xE73Iyuabi","time":1673542291,...} - - # Then use the message ID to update it - POST /mytopic/xE73Iyuabi HTTP/1.1 - Host: ntfy.sh - - Download 50% ... - - # Update again with the same sequence ID, this time using the header - POST /mytopic HTTP/1.1 - Host: ntfy.sh - X-Sequence-ID: xE73Iyuabi - - Download complete - ``` - -=== "JavaScript" - ``` javascript - // First, publish and get the message ID - const response = await fetch('https://ntfy.sh/mytopic', { - method: 'POST', - body: 'Downloading file...' - }); - const { id } = await response.json(); - - // Update via URL path - await fetch(`https://ntfy.sh/mytopic/${id}`, { - method: 'POST', - body: 'Download 50% ...' - }); - - // Or update using the X-Sequence-ID header - await fetch('https://ntfy.sh/mytopic', { - method: 'POST', - headers: { 'X-Sequence-ID': id }, - body: 'Download complete' - }); - ``` - -=== "Go" - ``` go - // Publish and parse the response to get the message ID - resp, _ := http.Post("https://ntfy.sh/mytopic", "text/plain", - strings.NewReader("Downloading file...")) - var msg struct { ID string `json:"id"` } - json.NewDecoder(resp.Body).Decode(&msg) - - // Update via URL path - http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain", - strings.NewReader("Download 50% ...")) - - // Or update using the X-Sequence-ID header - req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", - strings.NewReader("Download complete")) - req.Header.Set("X-Sequence-ID", msg.ID) - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - # Publish and get the message ID - $response = Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" -Body "Downloading file..." - $messageId = $response.id - - # Update via URL path - Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/$messageId" -Body "Download 50% ..." - - # Or update using the X-Sequence-ID header - Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" ` - -Headers @{"X-Sequence-ID"=$messageId} -Body "Download complete" - ``` - -=== "Python" - ``` python - import requests - - # Publish and get the message ID - response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...") - message_id = response.json()["id"] - - # Update via URL path - requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download 50% ...") - - # Or update using the X-Sequence-ID header - requests.post("https://ntfy.sh/mytopic", - headers={"X-Sequence-ID": message_id}, data="Download complete") - ``` - -=== "PHP" - ``` php-inline - // Publish and get the message ID - $response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ - 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] - ])); - $messageId = json_decode($response)->id; - - // Update via URL path - file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([ - 'http' => ['method' => 'POST', 'content' => 'Download 50% ...'] - ])); - - // Or update using the X-Sequence-ID header - file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "X-Sequence-ID: $messageId", - 'content' => 'Download complete' - ] - ])); - ``` - -You can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message. -**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to -`//`: - -=== "Command line (curl)" - ```bash - # Publish with a custom sequence ID - curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 - - # Update using the same sequence ID (via URL path) - curl -d "Download 50% ..." ntfy.sh/mytopic/my-download-123 - - # Or update using the X-Sequence-ID header - curl -H "X-Sequence-ID: my-download-123" -d "Download complete" ntfy.sh/mytopic - ``` - -=== "ntfy CLI" - ```bash - # Publish with a sequence ID - ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..." - - # Update using the same sequence ID - ntfy pub --sequence-id=my-download-123 mytopic "Download 50% ..." - - # Update again - ntfy pub -S my-download-123 mytopic "Download complete" - ``` - -=== "HTTP" - ``` http - # Publish a message with a custom sequence ID - POST /mytopic/my-download-123 HTTP/1.1 - Host: ntfy.sh - - Downloading file... - - # Update again using the X-Sequence-ID header - POST /mytopic HTTP/1.1 - Host: ntfy.sh - X-Sequence-ID: my-download-123 - - Download complete - ``` - -=== "JavaScript" - ``` javascript - // First message - await fetch('https://ntfy.sh/mytopic/my-download-123', { - method: 'POST', - body: 'Downloading file...' - }); - - // Update via URL path - await fetch('https://ntfy.sh/mytopic/my-download-123', { - method: 'POST', - body: 'Download 50% ...' - }); - - // Or update using the X-Sequence-ID header - await fetch('https://ntfy.sh/mytopic', { - method: 'POST', - headers: { 'X-Sequence-ID': 'my-download-123' }, - body: 'Download complete' - }); - ``` - -=== "Go" - ``` go - // Publish with sequence ID in URL path - http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", - strings.NewReader("Downloading file...")) - - // Update via URL path - http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", - strings.NewReader("Download 50% ...")) - - // Or update using the X-Sequence-ID header - req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", - strings.NewReader("Download complete")) - req.Header.Set("X-Sequence-ID", "my-download-123") - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - # Publish with sequence ID - Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..." - - # Update via URL path - Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download 50% ..." - - # Or update using the X-Sequence-ID header - Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" ` - -Headers @{"X-Sequence-ID"="my-download-123"} -Body "Download complete" - ``` - -=== "Python" - ``` python - import requests - - # Publish with sequence ID - requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...") - - # Update via URL path - requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download 50% ...") - - # Or update using the X-Sequence-ID header - requests.post("https://ntfy.sh/mytopic", - headers={"X-Sequence-ID": "my-download-123"}, data="Download complete") - ``` - -=== "PHP" - ``` php-inline - // Publish with sequence ID - file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ - 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] - ])); - - // Update via URL path - file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ - 'http' => ['method' => 'POST', 'content' => 'Download 50% ...'] - ])); - - // Or update using the X-Sequence-ID header - file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => 'X-Sequence-ID: my-download-123', - 'content' => 'Download complete' - ] - ])); - ``` - -You can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when -[publishing as JSON](#publish-as-json) using the `sequence_id` field. - -If the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id` -field the response. A sequence of updates may look like this (first example from above): - -```json -{"id":"xE73Iyuabi","time":1673542291,"event":"message","topic":"mytopic","message":"Downloading file..."} -{"id":"yF84Jzvbcj","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download 50% ..."} -{"id":"zG95Kawdde","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download complete"} -``` - -### Clearing notifications -Clearing a notification means **marking it as read and dismissing it from the notification drawer**. - -To do this, send a PUT request to the `///clear` endpoint (or `///read` as an alias). -This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status -and dismiss the notification. - -=== "Command line (curl)" - ```bash - curl -X PUT ntfy.sh/mytopic/my-download-123/clear - ``` - -=== "HTTP" - ``` http - PUT /mytopic/my-download-123/clear HTTP/1.1 - Host: ntfy.sh - ``` - -=== "JavaScript" - ``` javascript - await fetch('https://ntfy.sh/mytopic/my-download-123/clear', { - method: 'PUT' - }); - ``` - -=== "Go" - ``` go - req, _ := http.NewRequest("PUT", "https://ntfy.sh/mytopic/my-download-123/clear", nil) - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - Invoke-RestMethod -Method PUT -Uri "https://ntfy.sh/mytopic/my-download-123/clear" - ``` - -=== "Python" - ``` python - requests.put("https://ntfy.sh/mytopic/my-download-123/clear") - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([ - 'http' => ['method' => 'PUT'] - ])); - ``` - -An example response from the server with the `message_clear` event may look like this: - -```json -{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"my-download-123"} -``` - -### Deleting notifications -Deleting a notification means **removing it from the notification drawer and from the client's database**. - -To do this, send a DELETE request to the `//` endpoint. This will emit a `message_delete` event -that is used by the clients (web app and Android app) to remove the notification entirely. - -=== "Command line (curl)" - ```bash - curl -X DELETE ntfy.sh/mytopic/my-download-123 - ``` - -=== "HTTP" - ``` http - DELETE /mytopic/my-download-123 HTTP/1.1 - Host: ntfy.sh - ``` - -=== "JavaScript" - ``` javascript - await fetch('https://ntfy.sh/mytopic/my-download-123', { - method: 'DELETE' - }); - ``` - -=== "Go" - ``` go - req, _ := http.NewRequest("DELETE", "https://ntfy.sh/mytopic/my-download-123", nil) - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/my-download-123" - ``` - -=== "Python" - ``` python - requests.delete("https://ntfy.sh/mytopic/my-download-123") - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ - 'http' => ['method' => 'DELETE'] - ])); - ``` - -An example response from the server with the `message_delete` event may look like this: - -```json -{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"my-download-123"} -``` - -!!! info - Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will - reappear as a new message. - -## Message templating -_Supported on:_ :material-android: :material-apple: :material-firefox: - -Templating lets you **format a JSON message body into human-friendly message and title text** using -[Go templates](https://pkg.go.dev/text/template) (see tutorials [here](https://blog.gopheracademy.com/advent-2017/using-go-templates/), -[here](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go), and -[here](https://developer.hashicorp.com/nomad/tutorials/templates/go-template-syntax)). This is specifically useful when -**combined with webhooks** from services such as [GitHub](https://docs.github.com/en/webhooks/about-webhooks), -[Grafana](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/), -[Alertmanager](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config), or other services that emit JSON webhooks. - -Instead of using a separate bridge program to parse the webhook body into the format ntfy expects, you can include a templated -message and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body -is valid JSON). - -You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`): - -* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a pre-defined template name (one of `github`, - `grafana`, or `alertmanager`, such as `?template=github`) will use the built-in template with that name. - See [pre-defined templates](#pre-defined-templates) for more details. -* **Custom template files**: Setting the `X-Template` header or query parameter to a custom template name (e.g. `?template=myapp`) - will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`). - See [custom templates](#custom-templates) for more details. -* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`) - will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template. - See [inline templating](#inline-templating) for more details. - -To learn the basics of Go's templating language, please see [template syntax](#template-syntax). - -### Pre-defined templates - -When `X-Template: ` (aliases: `Template: `, `Tpl: `) or `?template=` is set, ntfy will transform the -message and/or title based on one of the built-in pre-defined templates. - -The following **pre-defined templates** are available: - -* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment). See [github.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/github.yml). -* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts). See [grafana.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/grafana.yml). -* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts). See [alertmanager.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/alertmanager.yml). - -To override the pre-defined templates, you can place a file with the same name in the template directory (defaults to `/etc/ntfy/templates`, -can be overridden with `template-dir`). See [custom templates](#custom-templates) for more details. - -Here's an example of how to use the **pre-defined `github` template**: - -First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`. -
    - ![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 } -
    GitHub webhook configuration
    -
    - -After that, when GitHub publishes a JSON webhook to the topic, ntfy will transform it according to the template rules -and you'll receive notifications in the ntfy app. Here's an example for when somebody stars your repository: - -
    - ![pre-defined template](static/img/android-screenshot-template-predefined.png){ width=500 } -
    Receiving a webhook, formatted using the pre-defined "github" template
    -
    - -### Custom templates - -To define **your own custom templates**, place a template file in the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`) -and set the `X-Template` header or query parameter to the name of the template file (without the `.yml` extension). - -For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or -the query parameter `?template=myapp` to use it. - -Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys, -which are interpreted as Go templates. - -Here's an **example custom template**: - -=== "Custom template (/etc/ntfy/templates/myapp.yml)" - ```yaml - title: | - {{- if eq .status "firing" }} - {{- if gt .percent 90.0 }}🚨 Critical alert - {{- else }}⚠️ Alert{{- end }} - {{- else if eq .status "resolved" }} - ✅ Alert resolved - {{- end }} - message: | - Status: {{ .status }} - Type: {{ .type | upper }} ({{ .percent }}%) - Server: {{ .server }} - ``` - -Once you have the template file in place, you can send the payload to your topic using the `X-Template` -header or query parameter: +Here's an example showing how to upload an image: === "Command line (curl)" ``` - echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \ - curl -sT- "https://ntfy.example.com/mytopic?template=myapp" - ``` - -=== "ntfy CLI" - ``` - echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \ - ntfy publish --template=myapp https://ntfy.example.com/mytopic - ``` - -=== "HTTP" - ``` http - POST /mytopic?template=myapp HTTP/1.1 - Host: ntfy.example.com - - { - "status": "firing", - "type": "cpu", - "server": "ntfy.sh", - "percent": 99 - } - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.example.com/mytopic?template=myapp', { - method: 'POST', - body: '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' - }) - ``` - -=== "Go" - ``` go - payload := `{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}` - req, _ := http.NewRequest("POST", "https://ntfy.example.com/mytopic?template=myapp", strings.NewReader(payload)) - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - $Request = @{ - Method = "POST" - Uri = "https://ntfy.example.com/mytopic?template=myapp" - Body = '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' - } - Invoke-RestMethod @Request - ``` - -=== "Python" - ``` python - requests.post("https://ntfy.example.com/mytopic?template=myapp", - json={"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}) - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.example.com/mytopic?template=myapp', false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json", - 'content' => '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' - ] - ])); - ``` - -Which will result in a notification that looks like this: - -
    - ![notification from custom JSON webhook template](static/img/android-screenshot-template-custom.png){ width=500 } -
    JSON webhook, transformed using a custom template
    -
    - -### Inline templating - -When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your -webhook payload. - -Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh). -Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead, -if you control the ntfy server, as templates are much easier to maintain. - -Here's an **example for a Grafana alert**: - -
    - ![notification with actions](static/img/android-screenshot-template.jpg){ width=500 } -
    Grafana webhook, formatted using templates
    -
    - -This was sent using the following templates and payloads - -=== "Message template" - ``` - {{range .alerts}} - {{.annotations.summary}} - - Values: - {{range $k,$v := .values}} - - {{$k}}={{$v}} - {{end}} - {{end}} - ``` - -=== "Title template" - ``` - {{.title}} - ``` - -=== "Encoded webhook URL" - ``` - # Additional URL encoding (see https://www.urlencoder.org/) is necessary for Grafana, - # and may be required for other tools too - - https://ntfy.sh/mytopic?tpl=1&t=%7B%7B.title%7D%7D&m=%7B%7Brange%20.alerts%7D%7D%7B%7B.annotations.summary%7D%7D%5Cn%5CnValues%3A%5Cn%7B%7Brange%20%24k%2C%24v%20%3A%3D%20.values%7D%7D-%20%7B%7B%24k%7D%7D%3D%7B%7B%24v%7D%7D%5Cn%7B%7Bend%7D%7D%7B%7Bend%7D%7D - ``` - -=== "Grafana-sent payload" - ``` - {"receiver":"ntfy\\.example\\.com/alerts","status":"resolved","alerts":[{"status":"resolved","labels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"annotations":{"summary":"15m load average too high"},"startsAt":"2024-03-15T02:28:00Z","endsAt":"2024-03-15T02:42:00Z","generatorURL":"localhost:3000/alerting/grafana/NW9oDw-4z/view","fingerprint":"becbfb94bd81ef48","silenceURL":"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter","dashboardURL":"","panelURL":"","values":{"B":18.98211314475876,"C":0},"valueString":"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"}],"groupLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts"},"commonLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"commonAnnotations":{"summary":"15m load average too high"},"externalURL":"localhost:3000/","version":"1","groupKey":"{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}","truncatedAlerts":0,"orgId":1,"title":"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)","state":"ok","message":"**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\n"} - ``` - -Here's an **easier example with a shorter JSON payload**: - -=== "Command line (curl)" - ``` - # To use { and } in the URL without encoding, we need to turn off - # curl's globbing using --globoff - curl \ - --globoff \ - -d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \ - 'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}' + -T flower.jpg \ + -H "Filename: flower.jpg" \ + ntfy.sh/flowers + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --file=flower.jpg \ + flowers ``` === "HTTP" ``` http - POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}} HTTP/1.1 + PUT /flowers HTTP/1.1 Host: ntfy.sh - - {"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}} + Filename: flower.jpg + Content-Type: 52312 + + (binary JPEG data) ``` === "JavaScript" ``` javascript - fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}', { - method: 'POST', - body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' + fetch('https://ntfy.sh/flowers', { + method: 'PUT', + body: document.getElementById("file").files[0], + headers: { 'Filename': 'flower.jpg' } }) ``` === "Go" ``` go - body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}` - uri := "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" - req, _ := http.NewRequest("POST", uri, strings.NewReader(body)) - http.DefaultClient.Do(req) - ``` - - -=== "PowerShell" - ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" - Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' - ContentType = "application/json" - } - Invoke-RestMethod @Request - ``` - -=== "Python" - ``` python - requests.post( - "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", - data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' - ) - ``` - -=== "PHP" - ``` php-inline - file_get_contents("https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json", - 'content' => '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' - ] - ])); - ``` - -This example uses the `message`/`m` and `title`/`t` query parameters, but obviously this also works with the corresponding -`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message -`Error message: Disk has run out of space`. - -### Template syntax -ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful, -yet also one of the worst templating languages out there. - -You can use the following features in your templates: - -* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}` -* Conditionals (if/else, e.g. `{{if eq .action "opened"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) -* Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) - -A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test -your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)). - -### Template functions -ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig), -thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload. - -Below are the functions that are available to use inside your message/title templates. - -* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc. -* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc. -* [Integer Math Functions](publish/template-functions.md#integer-math-functions): `add`, `max`, `mul`, etc. -* [Integer List Functions](publish/template-functions.md#integer-list-functions): `until`, `untilStep` -* [Float Math Functions](publish/template-functions.md#float-math-functions): `maxf`, `minf` -* [Date Functions](publish/template-functions.md#date-functions): `now`, `date`, etc. -* [Defaults Functions](publish/template-functions.md#default-functions): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` -* [Encoding Functions](publish/template-functions.md#encoding-functions): `b64enc`, `b64dec`, etc. -* [Lists and List Functions](publish/template-functions.md#lists-and-list-functions): `list`, `first`, `uniq`, etc. -* [Dictionaries and Dict Functions](publish/template-functions.md#dictionaries-and-dict-functions): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc. -* [Type Conversion Functions](publish/template-functions.md#type-conversion-functions): `atoi`, `int64`, `toString`, etc. -* [Path and Filepath Functions](publish/template-functions.md#path-and-filepath-functions): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` -* [Flow Control Functions](publish/template-functions.md#flow-control-functions): `fail` -* Advanced Functions - * [Reflection](publish/template-functions.md#reflection-functions): `typeOf`, `kindIs`, `typeIsLike`, etc. - * [Cryptographic and Security Functions](publish/template-functions.md#cryptographic-and-security-functions): `sha256sum`, etc. - * [URL](publish/template-functions.md#url-functions): `urlParse`, `urlJoin` - - -## Publish as JSON -_Supported on:_ :material-android: :material-apple: :material-firefox: - -For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)), -adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message -as JSON in the request body. - -To publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below -the example. - -!!! info - To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're - POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect). - -Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter -is the only required one: - -=== "Command line (curl)" - ``` - curl ntfy.sh \ - -d '{ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - }' - ``` - -=== "HTTP" - ``` http - POST / HTTP/1.1 - Host: ntfy.sh - - { - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - } - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh', { - method: 'POST', - body: JSON.stringify({ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - }) - }) - ``` - -=== "Go" - ``` go - // You should probably use json.Marshal() instead and make a proper struct, - // or even just use req.Header.Set() like in the other examples, but for the - // sake of the example, this is easier. - - body := `{ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - }` - req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body)) + file, _ := os.Open("flower.jpg") + req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file) + req.Header.Set("Filename", "flower.jpg") http.DefaultClient.Do(req) ``` @@ -2017,88 +1001,123 @@ is the only required one: ``` powershell $Request = @{ Method = "POST" - URI = "https://ntfy.sh" - Body = ConvertTo-JSON @{ - Topic = "mytopic" - Title = "Low disk space alert" - Message = "Disk space is low at 5.1 GB" - Priority = 4 - Attach = "https://filesrv.lan/space.jpg" - FileName = "diskspace.jpg" - Tags = @("warning", "cd") - Click = "https://homecamera.lan/xasds1h2xsSsa/" - Actions = @( - @{ - Action = "view" - Label = "Admin panel" - URL = "https://filesrv.lan/admin" - } - ) - } - ContentType = "application/json" + Uri = "ntfy.sh/flowers" + InFile = "flower.jpg" + Headers = @{"Filename" = "flower.jpg"} } Invoke-RestMethod @Request ``` === "Python" ``` python - requests.post("https://ntfy.sh/", - data=json.dumps({ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] - }) - ) + requests.put("https://ntfy.sh/flowers", + data=open("flower.jpg", 'rb'), + headers={ "Filename": "flower.jpg" }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/', false, stream_context_create([ + file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([ 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json", - 'content' => json_encode([ - "topic": "mytopic", - "message": "Disk space is low at 5.1 GB", - "title": "Low disk space alert", - "tags": ["warning","cd"], - "priority": 4, - "attach": "https://filesrv.lan/space.jpg", - "filename": "diskspace.jpg", - "click": "https://homecamera.lan/xasds1h2xsSsa/", - "actions": [["action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" ]] - ]) + 'method' => 'PUT', + 'header' => + "Content-Type: application/octet-stream\r\n" . // Does not matter + "Filename: flower.jpg", + 'content' => file_get_contents('flower.jpg') // Dangerous for large files ] ])); ``` -The JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md) -(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of -all the supported fields: +Here's what that looks like on Android: -| Field | Required | Type | Example | Description | -|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------| -| `topic` | ✔️ | *string* | `topic1` | Target topic name | -| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | -| `title` | - | *string* | `Some title` | Message [title](#message-title) | -| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | -| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | -| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | -| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | -| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) | -| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | -| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | -| `filename` | - | *string* | `file.jpg` | File name of the attachment | -| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | -| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | -| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | -| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | +
    + ![image attachment](static/img/android-screenshot-attachment-image.png){ width=500 } +
    Image attachment sent from a local file
    +
    + +### Attach file from a URL +Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted. +This could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are +externally hosted, the expiration or size limits from above do not apply here. + +To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`) +to specify the attachment URL. It can be any type of file. + +ntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a +filename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its +aliases `Filename`, `File` or `f`). + +Here's an example showing how to attach an APK file: + +=== "Command line (curl)" + ``` + curl \ + -X POST \ + -H "Attach: https://f-droid.org/F-Droid.apk" \ + ntfy.sh/mydownloads + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --attach="https://f-droid.org/F-Droid.apk" \ + mydownloads + ``` + +=== "HTTP" + ``` http + POST /mydownloads HTTP/1.1 + Host: ntfy.sh + Attach: https://f-droid.org/F-Droid.apk + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mydownloads', { + method: 'POST', + headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file) + req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mydownloads" + Headers = @{ Attach="https://f-droid.org/F-Droid.apk" } + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/mydownloads", + headers={ "Attach": "https://f-droid.org/F-Droid.apk" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'header' => + "Content-Type: text/plain\r\n" . // Does not matter + "Attach: https://f-droid.org/F-Droid.apk", + ] + ])); + ``` + +
    + ![file attachment](static/img/android-screenshot-attachment-file.png){ width=500 } +
    File attachment sent from an external URL
    +
    ## Action buttons _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -3266,65 +2285,61 @@ The `http` action supports the following fields: | `body` | -️ | *string* | *empty* | `some body, somebody?` | HTTP body | | `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. | -## Click action +## Scheduled delivery _Supported on:_ :material-android: :material-apple: :material-firefox: -You can define which URL to open when a notification is clicked. This may be useful if your notification is related -to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open -the web browser (or the app) and open the website. +You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself +reminders or even to execute commands at a later date (if your subscriber acts on messages). -To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its alias `Click`). -If you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled -by another app, the responsible app may open. +Usage is pretty straight forward. You can set the delivery time using the `X-Delay` header (or any of its aliases: `Delay`, +`X-At`, `At`, `X-In` or `In`), either by specifying a Unix timestamp (e.g. `1639194738`), a duration (e.g. `30m`, +`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`, +[and more](https://github.com/olebedev/when)). -Examples: +As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured +with the `message-delay-limit` option. -* `http://` or `https://` will open your browser (or an app if it registered for a URL) -* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com` -* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA` -* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats` -* `twitter://` links will open Twitter, e.g. `twitter://user?screen_name=..` -* ... - -Here's an example that will open Reddit when the notification is clicked: +For the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours +after they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled +to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Also note that naturally, +[turning off server-side caching](#message-caching) is not possible in combination with this feature. === "Command line (curl)" ``` - curl \ - -d "New messages on Reddit" \ - -H "Click: https://www.reddit.com/message/messages" \ - ntfy.sh/reddit_alerts + curl -H "At: tomorrow, 10am" -d "Good morning" ntfy.sh/hello + curl -H "In: 30min" -d "It's 30 minutes later now" ntfy.sh/reminder + curl -H "Delay: 1639194738" -d "Unix timestamps are awesome" ntfy.sh/itsaunixsystem ``` === "ntfy CLI" ``` ntfy publish \ - --click="https://www.reddit.com/message/messages" \ - reddit_alerts "New messages on Reddit" + --at="tomorrow, 10am" \ + hello "Good morning" ``` === "HTTP" ``` http - POST /reddit_alerts HTTP/1.1 + POST /hello HTTP/1.1 Host: ntfy.sh - Click: https://www.reddit.com/message/messages + At: tomorrow, 10am - New messages on Reddit + Good morning ``` === "JavaScript" ``` javascript - fetch('https://ntfy.sh/reddit_alerts', { + fetch('https://ntfy.sh/hello', { method: 'POST', - body: 'New messages on Reddit', - headers: { 'Click': 'https://www.reddit.com/message/messages' } + body: 'Good morning', + headers: { 'At': 'tomorrow, 10am' } }) ``` === "Go" ``` go - req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit")) - req.Header.Set("Click", "https://www.reddit.com/message/messages") + req, _ := http.NewRequest("POST", "https://ntfy.sh/hello", strings.NewReader("Good morning")) + req.Header.Set("At", "tomorrow, 10am") http.DefaultClient.Do(req) ``` @@ -3332,332 +2347,700 @@ Here's an example that will open Reddit when the notification is clicked: ``` powershell $Request = @{ Method = "POST" - URI = "https://ntfy.sh/reddit_alerts" - Headers = @{ Click="https://www.reddit.com/message/messages" } - Body = "New messages on Reddit" + URI = "https://ntfy.sh/hello" + Headers = @{ + At = "tomorrow, 10am" + } + Body = "Good morning" } Invoke-RestMethod @Request ``` - + === "Python" ``` python - requests.post("https://ntfy.sh/reddit_alerts", - data="New messages on Reddit", - headers={ "Click": "https://www.reddit.com/message/messages" }) + requests.post("https://ntfy.sh/hello", + data="Good morning", + headers={ "At": "tomorrow, 10am" }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ + file_get_contents('https://ntfy.sh/backups', false, stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: text/plain\r\n" . - "Click: https://www.reddit.com/message/messages", - 'content' => 'New messages on Reddit' + "At: tomorrow, 10am", + 'content' => 'Good morning' ] ])); ``` -## Attachments -_Supported on:_ :material-android: :material-firefox: +Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**): -You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded -onto your phone (depending on size and setting automatically), and can be used from the Downloads folder. + + +
    + + + + + + + +
    Delay/At/In headerMessage will be delivered atExplanation
    30m12/10/2021, 9:30am30 minutes from now
    2 hours12/10/2021, 11:30am2 hours from now
    1 day12/11/2021, 9am24 hours from now
    10am12/10/2021, 10amToday at 10am (same day, because it's only 9am)
    8am12/11/2021, 8amTomorrow at 8am (because it's 9am already)
    163915200012/10/2021, 11am (EST) Today at 11am (EST)
    +
    -There are two different ways to send attachments: +### Updating scheduled notifications +You can update or replace a scheduled message before it is delivered by publishing a new message with the same +[sequence ID](#updating-deleting-notifications). When you do this, the **original scheduled message is deleted** +from the server and replaced with the new one. This is different from [updating notifications](#updating-notifications) +after delivery, where both messages are kept in the cache. -* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3` -* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk` +This is particularly useful for implementing a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) — +a mechanism that triggers an alert if it's not periodically reset. For example, you could schedule a message to be +delivered in 5 minutes, but continuously update it every minute to push the delivery time further into the future. +If your script or system stops running, the message will eventually be delivered as an alert. -### Attach local file -To **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater -than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically -detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files -as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases -`Filename`, `File` or `f`). - -By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor). -Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app -to auto-download it. Please also check out the [other limits below](#limitations). - -Here's an example showing how to upload an image: +Here's an example of a dead man's switch that sends an alert if the script stops running for more than 5 minutes: === "Command line (curl)" - ``` - curl \ - -T flower.jpg \ - -H "Filename: flower.jpg" \ - ntfy.sh/flowers + ```bash + # Dead man's switch: keeps pushing a scheduled message into the future + # If this script stops, the alert will be delivered after 5 minutes + while true; do + curl -H "In: 5m" -d "Warning: Server heartbeat stopped!" \ + ntfy.sh/mytopic/heartbeat-check + sleep 60 # Update every minute + done ``` === "ntfy CLI" - ``` - ntfy publish \ - --file=flower.jpg \ - flowers + ```bash + # Dead man's switch: keeps pushing a scheduled message into the future + # If this script stops, the alert will be delivered after 5 minutes + while true; do + ntfy publish \ + --in="5m" \ + --sequence-id="heartbeat-check" \ + mytopic "Warning: Server heartbeat stopped!" + sleep 60 # Update every minute + done ``` === "HTTP" ``` http - PUT /flowers HTTP/1.1 + POST /mytopic/heartbeat-check HTTP/1.1 Host: ntfy.sh - Filename: flower.jpg - Content-Type: 52312 - - (binary JPEG data) + In: 5m + + Warning: Server heartbeat stopped! ``` === "JavaScript" ``` javascript - fetch('https://ntfy.sh/flowers', { - method: 'PUT', - body: document.getElementById("file").files[0], - headers: { 'Filename': 'flower.jpg' } - }) - ``` - -=== "Go" - ``` go - file, _ := os.Open("flower.jpg") - req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file) - req.Header.Set("Filename", "flower.jpg") - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - $Request = @{ - Method = "POST" - Uri = "ntfy.sh/flowers" - InFile = "flower.jpg" - Headers = @{"Filename" = "flower.jpg"} - } - Invoke-RestMethod @Request - ``` - -=== "Python" - ``` python - requests.put("https://ntfy.sh/flowers", - data=open("flower.jpg", 'rb'), - headers={ "Filename": "flower.jpg" }) - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([ - 'http' => [ - 'method' => 'PUT', - 'header' => - "Content-Type: application/octet-stream\r\n" . // Does not matter - "Filename: flower.jpg", - 'content' => file_get_contents('flower.jpg') // Dangerous for large files - ] - ])); - ``` - -Here's what that looks like on Android: - -
    - ![image attachment](static/img/android-screenshot-attachment-image.png){ width=500 } -
    Image attachment sent from a local file
    -
    - -### Attach file from a URL -Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted. -This could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are -externally hosted, the expiration or size limits from above do not apply here. - -To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`) -to specify the attachment URL. It can be any type of file. - -ntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a -filename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its -aliases `Filename`, `File` or `f`). - -Here's an example showing how to attach an APK file: - -=== "Command line (curl)" - ``` - curl \ - -X POST \ - -H "Attach: https://f-droid.org/F-Droid.apk" \ - ntfy.sh/mydownloads - ``` - -=== "ntfy CLI" - ``` - ntfy publish \ - --attach="https://f-droid.org/F-Droid.apk" \ - mydownloads - ``` - -=== "HTTP" - ``` http - POST /mydownloads HTTP/1.1 - Host: ntfy.sh - Attach: https://f-droid.org/F-Droid.apk - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh/mydownloads', { - method: 'POST', - headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' } - }) - ``` - -=== "Go" - ``` go - req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file) - req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk") - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mydownloads" - Headers = @{ Attach="https://f-droid.org/F-Droid.apk" } - } - Invoke-RestMethod @Request - ``` - -=== "Python" - ``` python - requests.put("https://ntfy.sh/mydownloads", - headers={ "Attach": "https://f-droid.org/F-Droid.apk" }) - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([ - 'http' => [ - 'method' => 'PUT', - 'header' => - "Content-Type: text/plain\r\n" . // Does not matter - "Attach: https://f-droid.org/F-Droid.apk", - ] - ])); - ``` - -
    - ![file attachment](static/img/android-screenshot-attachment-file.png){ width=500 } -
    File attachment sent from an external URL
    -
    - -## Icons -_Supported on:_ :material-android: - -You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query -parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download -the icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are -cached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**. - -Here's an example showing how to include an icon: - -=== "Command line (curl)" - ``` - curl \ - -H "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ - -H "Title: Kodi: Resuming Playback" \ - -H "Tags: arrow_forward" \ - -d "The Wire, S01E01" \ - ntfy.sh/tvshows - ``` - -=== "ntfy CLI" - ``` - ntfy publish \ - --icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \ - --title="Kodi: Resuming Playback" \ - --tags="arrow_forward" \ - tvshows \ - "The Wire, S01E01" - ``` - -=== "HTTP" - ``` http - POST /tvshows HTTP/1.1 - Host: ntfy.sh - Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png - Tags: arrow_forward - Title: Kodi: Resuming Playback - - The Wire, S01E01 - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh/tvshows', { - method: 'POST', - headers: { - 'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png', - 'Title': 'Kodi: Resuming Playback', - 'Tags': 'arrow_forward' - }, - body: "The Wire, S01E01" - }) - ``` - -=== "Go" - ``` go - req, _ := http.NewRequest("POST", "https://ntfy.sh/tvshows", strings.NewReader("The Wire, S01E01")) - req.Header.Set("Icon", "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png") - req.Header.Set("Tags", "arrow_forward") - req.Header.Set("Title", "Kodi: Resuming Playback") - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/tvshows" - Headers = @{ - Title = "Kodi: Resuming Playback" - Tags = "arrow_forward" - Icon = "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" - } - Body = "The Wire, S01E01" - } - Invoke-RestMethod @Request - ``` - -=== "Python" - ``` python - requests.post("https://ntfy.sh/tvshows", - data="The Wire, S01E01", - headers={ - "Title": "Kodi: Resuming Playback", - "Tags": "arrow_forward", - "Icon": "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" + // Dead man's switch: keeps pushing a scheduled message into the future + // If this script stops, the alert will be delivered after 5 minutes + setInterval(() => { + fetch('https://ntfy.sh/mytopic/heartbeat-check', { + method: 'POST', + body: 'Warning: Server heartbeat stopped!', + headers: { 'In': '5m' } }) + }, 60000) // Update every minute + ``` + +=== "Go" + ``` go + // Dead man's switch: keeps pushing a scheduled message into the future + // If this script stops, the alert will be delivered after 5 minutes + for { + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic/heartbeat-check", + strings.NewReader("Warning: Server heartbeat stopped!")) + req.Header.Set("In", "5m") + http.DefaultClient.Do(req) + time.Sleep(60 * time.Second) // Update every minute + } + ``` + +=== "PowerShell" + ``` powershell + # Dead man's switch: keeps pushing a scheduled message into the future + # If this script stops, the alert will be delivered after 5 minutes + while ($true) { + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic/heartbeat-check" + Headers = @{ In = "5m" } + Body = "Warning: Server heartbeat stopped!" + } + Invoke-RestMethod @Request + Start-Sleep -Seconds 60 # Update every minute + } + ``` + +=== "Python" + ``` python + import requests + import time + + # Dead man's switch: keeps pushing a scheduled message into the future + # If this script stops, the alert will be delivered after 5 minutes + while True: + requests.post( + "https://ntfy.sh/mytopic/heartbeat-check", + data="Warning: Server heartbeat stopped!", + headers={"In": "5m"} + ) + time.sleep(60) # Update every minute ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([ + // Dead man's switch: keeps pushing a scheduled message into the future + // If this script stops, the alert will be delivered after 5 minutes + while (true) { + file_get_contents('https://ntfy.sh/mytopic/heartbeat-check', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\nIn: 5m", + 'content' => 'Warning: Server heartbeat stopped!' + ] + ])); + sleep(60); // Update every minute + } + ``` + +### Canceling scheduled notifications +You can cancel a scheduled message before it is delivered by sending a DELETE request to the +`//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the +scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers. + +=== "Command line (curl)" + ```bash + # Schedule a reminder for 2 hours from now + curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder + + # Changed your mind? Cancel the scheduled message + curl -X DELETE ntfy.sh/mytopic/break-reminder + ``` + +=== "ntfy CLI" + ```bash + # Schedule a reminder for 2 hours from now + ntfy publish --in="2h" mytopic/break-reminder "Take a break!" + + # Changed your mind? Cancel the scheduled message + # (ntfy CLI does not support DELETE, use curl instead) + curl -X DELETE ntfy.sh/mytopic/break-reminder + ``` + +=== "HTTP" + ``` http + DELETE /mytopic/break-reminder HTTP/1.1 + Host: ntfy.sh + ``` + +=== "JavaScript" + ``` javascript + // Schedule a reminder for 2 hours from now + await fetch('https://ntfy.sh/mytopic/break-reminder', { + method: 'POST', + body: 'Take a break!', + headers: { 'In': '2h' } + }); + + // Changed your mind? Cancel the scheduled message + await fetch('https://ntfy.sh/mytopic/break-reminder', { + method: 'DELETE' + }); + ``` + +=== "Go" + ``` go + // Schedule a reminder for 2 hours from now + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic/break-reminder", + strings.NewReader("Take a break!")) + req.Header.Set("In", "2h") + http.DefaultClient.Do(req) + + // Changed your mind? Cancel the scheduled message + req, _ = http.NewRequest("DELETE", "https://ntfy.sh/mytopic/break-reminder", nil) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Schedule a reminder for 2 hours from now + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic/break-reminder" + Headers = @{ In = "2h" } + Body = "Take a break!" + } + Invoke-RestMethod @Request + + # Changed your mind? Cancel the scheduled message + Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/break-reminder" + ``` + +=== "Python" + ``` python + import requests + + # Schedule a reminder for 2 hours from now + requests.post( + "https://ntfy.sh/mytopic/break-reminder", + data="Take a break!", + headers={"In": "2h"} + ) + + # Changed your mind? Cancel the scheduled message + requests.delete("https://ntfy.sh/mytopic/break-reminder") + ``` + +=== "PHP" + ``` php-inline + // Schedule a reminder for 2 hours from now + file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([ 'http' => [ - 'method' => 'PUT', - 'header' => - "Content-Type: text/plain\r\n" . // Does not matter - "Title: Kodi: Resuming Playback\r\n" . - "Tags: arrow_forward\r\n" . - "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png", - ], - 'content' => "The Wire, S01E01" + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\nIn: 2h", + 'content' => 'Take a break!' + ] + ])); + + // Changed your mind? Cancel the scheduled message + file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([ + 'http' => ['method' => 'DELETE'] ])); ``` -Here's an example of how it will look on Android: +## Webhooks (publish via GET) +_Supported on:_ :material-android: :material-apple: :material-firefox: + +In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use +a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support. + +To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without +any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are +also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all +[supported parameters and headers](#list-of-all-parameters) for details. + +For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message +(aka trigger the webhook): + +=== "Command line (curl)" + ``` + curl ntfy.sh/mywebhook/trigger + ``` + +=== "ntfy CLI" + ``` + ntfy trigger mywebhook + ``` + +=== "HTTP" + ``` http + GET /mywebhook/trigger HTTP/1.1 + Host: ntfy.sh + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mywebhook/trigger') + ``` + +=== "Go" + ``` go + http.Get("https://ntfy.sh/mywebhook/trigger") + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod "ntfy.sh/mywebhook/trigger" + ``` + +=== "Python" + ``` python + requests.get("https://ntfy.sh/mywebhook/trigger") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mywebhook/trigger'); + ``` + +To add a custom message, simply append the `message=` URL parameter. And of course you can set the +[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well. +For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters). + +Here's an example with a custom message, tags and a priority: + +=== "Command line (curl)" + ``` + curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -p 5 --tags=warning,skull \ + mywebhook "Webhook triggered" + ``` + +=== "HTTP" + ``` http + GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1 + Host: ntfy.sh + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull') + ``` + +=== "Go" + ``` go + http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + ``` + +=== "Python" + ``` python + requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); + ``` + +## Message templating +_Supported on:_ :material-android: :material-apple: :material-firefox: + +Templating lets you **format a JSON message body into human-friendly message and title text** using +[Go templates](https://pkg.go.dev/text/template) (see tutorials [here](https://blog.gopheracademy.com/advent-2017/using-go-templates/), +[here](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go), and +[here](https://developer.hashicorp.com/nomad/tutorials/templates/go-template-syntax)). This is specifically useful when +**combined with webhooks** from services such as [GitHub](https://docs.github.com/en/webhooks/about-webhooks), +[Grafana](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/), +[Alertmanager](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config), or other services that emit JSON webhooks. + +Instead of using a separate bridge program to parse the webhook body into the format ntfy expects, you can include a templated +message and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body +is valid JSON). + +You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`): + +* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a pre-defined template name (one of `github`, + `grafana`, or `alertmanager`, such as `?template=github`) will use the built-in template with that name. + See [pre-defined templates](#pre-defined-templates) for more details. +* **Custom template files**: Setting the `X-Template` header or query parameter to a custom template name (e.g. `?template=myapp`) + will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`). + See [custom templates](#custom-templates) for more details. +* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`) + will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template. + See [inline templating](#inline-templating) for more details. + +To learn the basics of Go's templating language, please see [template syntax](#template-syntax). + +### Pre-defined templates + +When `X-Template: ` (aliases: `Template: `, `Tpl: `) or `?template=` is set, ntfy will transform the +message and/or title based on one of the built-in pre-defined templates. + +The following **pre-defined templates** are available: + +* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment). See [github.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/github.yml). +* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts). See [grafana.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/grafana.yml). +* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts). See [alertmanager.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/alertmanager.yml). + +To override the pre-defined templates, you can place a file with the same name in the template directory (defaults to `/etc/ntfy/templates`, +can be overridden with `template-dir`). See [custom templates](#custom-templates) for more details. + +Here's an example of how to use the **pre-defined `github` template**: + +First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`. +
    + ![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 } +
    GitHub webhook configuration
    +
    + +After that, when GitHub publishes a JSON webhook to the topic, ntfy will transform it according to the template rules +and you'll receive notifications in the ntfy app. Here's an example for when somebody stars your repository:
    - ![file attachment](static/img/android-screenshot-icon.png){ width=500 } -
    Custom icon from an external URL
    + ![pre-defined template](static/img/android-screenshot-template-predefined.png){ width=500 } +
    Receiving a webhook, formatted using the pre-defined "github" template
    +### Custom templates + +To define **your own custom templates**, place a template file in the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`) +and set the `X-Template` header or query parameter to the name of the template file (without the `.yml` extension). + +For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or +the query parameter `?template=myapp` to use it. + +Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys, +which are interpreted as Go templates. + +Here's an **example custom template**: + +=== "Custom template (/etc/ntfy/templates/myapp.yml)" + ```yaml + title: | + {{- if eq .status "firing" }} + {{- if gt .percent 90.0 }}🚨 Critical alert + {{- else }}⚠️ Alert{{- end }} + {{- else if eq .status "resolved" }} + ✅ Alert resolved + {{- end }} + message: | + Status: {{ .status }} + Type: {{ .type | upper }} ({{ .percent }}%) + Server: {{ .server }} + ``` + +Once you have the template file in place, you can send the payload to your topic using the `X-Template` +header or query parameter: + +=== "Command line (curl)" + ``` + echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \ + curl -sT- "https://ntfy.example.com/mytopic?template=myapp" + ``` + +=== "ntfy CLI" + ``` + echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \ + ntfy publish --template=myapp https://ntfy.example.com/mytopic + ``` + +=== "HTTP" + ``` http + POST /mytopic?template=myapp HTTP/1.1 + Host: ntfy.example.com + + { + "status": "firing", + "type": "cpu", + "server": "ntfy.sh", + "percent": 99 + } + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mytopic?template=myapp', { + method: 'POST', + body: '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + }) + ``` + +=== "Go" + ``` go + payload := `{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}` + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mytopic?template=myapp", strings.NewReader(payload)) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + Uri = "https://ntfy.example.com/mytopic?template=myapp" + Body = '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mytopic?template=myapp", + json={"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mytopic?template=myapp', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + ] + ])); + ``` + +Which will result in a notification that looks like this: + +
    + ![notification from custom JSON webhook template](static/img/android-screenshot-template-custom.png){ width=500 } +
    JSON webhook, transformed using a custom template
    +
    + +### Inline templating + +When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your +webhook payload. + +Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh). +Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead, +if you control the ntfy server, as templates are much easier to maintain. + +Here's an **example for a Grafana alert**: + +
    + ![notification with actions](static/img/android-screenshot-template.jpg){ width=500 } +
    Grafana webhook, formatted using templates
    +
    + +This was sent using the following templates and payloads + +=== "Message template" + ``` + {{range .alerts}} + {{.annotations.summary}} + + Values: + {{range $k,$v := .values}} + - {{$k}}={{$v}} + {{end}} + {{end}} + ``` + +=== "Title template" + ``` + {{.title}} + ``` + +=== "Encoded webhook URL" + ``` + # Additional URL encoding (see https://www.urlencoder.org/) is necessary for Grafana, + # and may be required for other tools too + + https://ntfy.sh/mytopic?tpl=1&t=%7B%7B.title%7D%7D&m=%7B%7Brange%20.alerts%7D%7D%7B%7B.annotations.summary%7D%7D%5Cn%5CnValues%3A%5Cn%7B%7Brange%20%24k%2C%24v%20%3A%3D%20.values%7D%7D-%20%7B%7B%24k%7D%7D%3D%7B%7B%24v%7D%7D%5Cn%7B%7Bend%7D%7D%7B%7Bend%7D%7D + ``` + +=== "Grafana-sent payload" + ``` + {"receiver":"ntfy\\.example\\.com/alerts","status":"resolved","alerts":[{"status":"resolved","labels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"annotations":{"summary":"15m load average too high"},"startsAt":"2024-03-15T02:28:00Z","endsAt":"2024-03-15T02:42:00Z","generatorURL":"localhost:3000/alerting/grafana/NW9oDw-4z/view","fingerprint":"becbfb94bd81ef48","silenceURL":"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter","dashboardURL":"","panelURL":"","values":{"B":18.98211314475876,"C":0},"valueString":"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"}],"groupLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts"},"commonLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"commonAnnotations":{"summary":"15m load average too high"},"externalURL":"localhost:3000/","version":"1","groupKey":"{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}","truncatedAlerts":0,"orgId":1,"title":"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)","state":"ok","message":"**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\n"} + ``` + +Here's an **easier example with a shorter JSON payload**: + +=== "Command line (curl)" + ``` + # To use { and } in the URL without encoding, we need to turn off + # curl's globbing using --globoff + + curl \ + --globoff \ + -d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \ + 'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}' + ``` + +=== "HTTP" + ``` http + POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}} HTTP/1.1 + Host: ntfy.sh + + {"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}} + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}', { + method: 'POST', + body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' + }) + ``` + +=== "Go" + ``` go + body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}` + uri := "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" + req, _ := http.NewRequest("POST", uri, strings.NewReader(body)) + http.DefaultClient.Do(req) + ``` + + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" + Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' + ContentType = "application/json" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post( + "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", + data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' + ) + ``` + +=== "PHP" + ``` php-inline + file_get_contents("https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' + ] + ])); + ``` + +This example uses the `message`/`m` and `title`/`t` query parameters, but obviously this also works with the corresponding +`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message +`Error message: Disk has run out of space`. + +### Template syntax +ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful, +yet also one of the worst templating languages out there. + +You can use the following features in your templates: + +* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}` +* Conditionals (if/else, e.g. `{{if eq .action "opened"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) +* Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) + +A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test +your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)). + +### Template functions +ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig), +thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload. + +Below are the functions that are available to use inside your message/title templates. + +* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc. +* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc. +* [Integer Math Functions](publish/template-functions.md#integer-math-functions): `add`, `max`, `mul`, etc. +* [Integer List Functions](publish/template-functions.md#integer-list-functions): `until`, `untilStep` +* [Float Math Functions](publish/template-functions.md#float-math-functions): `maxf`, `minf` +* [Date Functions](publish/template-functions.md#date-functions): `now`, `date`, etc. +* [Defaults Functions](publish/template-functions.md#default-functions): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` +* [Encoding Functions](publish/template-functions.md#encoding-functions): `b64enc`, `b64dec`, etc. +* [Lists and List Functions](publish/template-functions.md#lists-and-list-functions): `list`, `first`, `uniq`, etc. +* [Dictionaries and Dict Functions](publish/template-functions.md#dictionaries-and-dict-functions): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc. +* [Type Conversion Functions](publish/template-functions.md#type-conversion-functions): `atoi`, `int64`, `toString`, etc. +* [Path and Filepath Functions](publish/template-functions.md#path-and-filepath-functions): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` +* [Flow Control Functions](publish/template-functions.md#flow-control-functions): `fail` +* Advanced Functions + * [Reflection](publish/template-functions.md#reflection-functions): `typeOf`, `kindIs`, `typeIsLike`, etc. + * [Cryptographic and Security Functions](publish/template-functions.md#cryptographic-and-security-functions): `sha256sum`, etc. + * [URL](publish/template-functions.md#url-functions): `urlParse`, `urlJoin` + ## E-mail notifications _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -3933,10 +3316,626 @@ Here's what a phone call from ntfy sounds like: Audio transcript: -> You have a notification from ntfy on topic alerts. +> You have a notification from ntfy on topic alerts. > Message: Your garage seems to be on fire. You should probably check that out. End message. > This message was sent by user phil. It will be repeated up to three times. +## Publish as JSON +_Supported on:_ :material-android: :material-apple: :material-firefox: + +For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)), +adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message +as JSON in the request body. + +To publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below +the example. + +!!! info + To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're + POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect). + +Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter +is the only required one: + +=== "Command line (curl)" + ``` + curl ntfy.sh \ + -d '{ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/", + "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] + }' + ``` + +=== "HTTP" + ``` http + POST / HTTP/1.1 + Host: ntfy.sh + + { + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/", + "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] + } + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh', { + method: 'POST', + body: JSON.stringify({ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/", + "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] + }) + }) + ``` + +=== "Go" + ``` go + // You should probably use json.Marshal() instead and make a proper struct, + // or even just use req.Header.Set() like in the other examples, but for the + // sake of the example, this is easier. + + body := `{ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/", + "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] + }` + req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body)) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh" + Body = ConvertTo-JSON @{ + Topic = "mytopic" + Title = "Low disk space alert" + Message = "Disk space is low at 5.1 GB" + Priority = 4 + Attach = "https://filesrv.lan/space.jpg" + FileName = "diskspace.jpg" + Tags = @("warning", "cd") + Click = "https://homecamera.lan/xasds1h2xsSsa/" + Actions = @( + @{ + Action = "view" + Label = "Admin panel" + URL = "https://filesrv.lan/admin" + } + ) + } + ContentType = "application/json" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/", + data=json.dumps({ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/", + "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] + }) + ) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => json_encode([ + "topic": "mytopic", + "message": "Disk space is low at 5.1 GB", + "title": "Low disk space alert", + "tags": ["warning","cd"], + "priority": 4, + "attach": "https://filesrv.lan/space.jpg", + "filename": "diskspace.jpg", + "click": "https://homecamera.lan/xasds1h2xsSsa/", + "actions": [["action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" ]] + ]) + ] + ])); + ``` + +The JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md) +(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of +all the supported fields: + +| Field | Required | Type | Example | Description | +|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------| +| `topic` | ✔️ | *string* | `topic1` | Target topic name | +| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | +| `title` | - | *string* | `Some title` | Message [title](#message-title) | +| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | +| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | +| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | +| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | +| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) | +| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | +| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | +| `filename` | - | *string* | `file.jpg` | File name of the attachment | +| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | +| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | +| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | +| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | + +## Updating + deleting notifications +_Supported on:_ :material-android: :material-firefox: + +!!! info + **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. + +You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios +like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. + +* [Updating notifications](#updating-notifications) will alter the content of an existing notification. +* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer. +* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported). + +Here's an example of a download progress notification being updated over time on Android: + +
    + + +
    + +To facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence, +using a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the +same sequence ID and the clients will perform the appropriate action on the existing notification. + +Existing ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates +the update, clear, or delete action. This append-only behavior ensures that message history remains intact. + +### Updating notifications +To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous +notification with the new one. You can either: + +1. **Use the message ID**: First publish like normal to `POST /` without a sequence ID, then use the returned message `id` as the sequence ID for updates +2. **Use a custom sequence ID**: Publish directly to `POST //` with your own identifier, or use `POST /` with the + `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`) + +If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned +message `id` to update it. Here's an example: + +=== "Command line (curl)" + ```bash + # First, publish a message and capture the message ID + curl -d "Downloading file..." ntfy.sh/mytopic + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it (via URL path) + curl -d "Download 50% ..." ntfy.sh/mytopic/xE73Iyuabi + + # Or update using the X-Sequence-ID header + curl -H "X-Sequence-ID: xE73Iyuabi" -d "Download complete" ntfy.sh/mytopic + ``` + +=== "ntfy CLI" + ```bash + # First, publish a message and capture the message ID + ntfy pub mytopic "Downloading file..." + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + ntfy pub --sequence-id=xE73Iyuabi mytopic "Download 50% ..." + + # Update again with the same sequence ID + ntfy pub -S xE73Iyuabi mytopic "Download complete" + ``` + +=== "HTTP" + ``` http + # First, publish a message and capture the message ID + POST /mytopic HTTP/1.1 + Host: ntfy.sh + + Downloading file... + + # Returns: {"id":"xE73Iyuabi","time":1673542291,...} + + # Then use the message ID to update it + POST /mytopic/xE73Iyuabi HTTP/1.1 + Host: ntfy.sh + + Download 50% ... + + # Update again with the same sequence ID, this time using the header + POST /mytopic HTTP/1.1 + Host: ntfy.sh + X-Sequence-ID: xE73Iyuabi + + Download complete + ``` + +=== "JavaScript" + ``` javascript + // First, publish and get the message ID + const response = await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + body: 'Downloading file...' + }); + const { id } = await response.json(); + + // Update via URL path + await fetch(`https://ntfy.sh/mytopic/${id}`, { + method: 'POST', + body: 'Download 50% ...' + }); + + // Or update using the X-Sequence-ID header + await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + headers: { 'X-Sequence-ID': id }, + body: 'Download complete' + }); + ``` + +=== "Go" + ``` go + // Publish and parse the response to get the message ID + resp, _ := http.Post("https://ntfy.sh/mytopic", "text/plain", + strings.NewReader("Downloading file...")) + var msg struct { ID string `json:"id"` } + json.NewDecoder(resp.Body).Decode(&msg) + + // Update via URL path + http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain", + strings.NewReader("Download 50% ...")) + + // Or update using the X-Sequence-ID header + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", + strings.NewReader("Download complete")) + req.Header.Set("X-Sequence-ID", msg.ID) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Publish and get the message ID + $response = Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" -Body "Downloading file..." + $messageId = $response.id + + # Update via URL path + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/$messageId" -Body "Download 50% ..." + + # Or update using the X-Sequence-ID header + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" ` + -Headers @{"X-Sequence-ID"=$messageId} -Body "Download complete" + ``` + +=== "Python" + ``` python + import requests + + # Publish and get the message ID + response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...") + message_id = response.json()["id"] + + # Update via URL path + requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download 50% ...") + + # Or update using the X-Sequence-ID header + requests.post("https://ntfy.sh/mytopic", + headers={"X-Sequence-ID": message_id}, data="Download complete") + ``` + +=== "PHP" + ``` php-inline + // Publish and get the message ID + $response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] + ])); + $messageId = json_decode($response)->id; + + // Update via URL path + file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Download 50% ...'] + ])); + + // Or update using the X-Sequence-ID header + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "X-Sequence-ID: $messageId", + 'content' => 'Download complete' + ] + ])); + ``` + +You can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message. +**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to +`//`: + +=== "Command line (curl)" + ```bash + # Publish with a custom sequence ID + curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123 + + # Update using the same sequence ID (via URL path) + curl -d "Download 50% ..." ntfy.sh/mytopic/my-download-123 + + # Or update using the X-Sequence-ID header + curl -H "X-Sequence-ID: my-download-123" -d "Download complete" ntfy.sh/mytopic + ``` + +=== "ntfy CLI" + ```bash + # Publish with a sequence ID + ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..." + + # Update using the same sequence ID + ntfy pub --sequence-id=my-download-123 mytopic "Download 50% ..." + + # Update again + ntfy pub -S my-download-123 mytopic "Download complete" + ``` + +=== "HTTP" + ``` http + # Publish a message with a custom sequence ID + POST /mytopic/my-download-123 HTTP/1.1 + Host: ntfy.sh + + Downloading file... + + # Update again using the X-Sequence-ID header + POST /mytopic HTTP/1.1 + Host: ntfy.sh + X-Sequence-ID: my-download-123 + + Download complete + ``` + +=== "JavaScript" + ``` javascript + // First message + await fetch('https://ntfy.sh/mytopic/my-download-123', { + method: 'POST', + body: 'Downloading file...' + }); + + // Update via URL path + await fetch('https://ntfy.sh/mytopic/my-download-123', { + method: 'POST', + body: 'Download 50% ...' + }); + + // Or update using the X-Sequence-ID header + await fetch('https://ntfy.sh/mytopic', { + method: 'POST', + headers: { 'X-Sequence-ID': 'my-download-123' }, + body: 'Download complete' + }); + ``` + +=== "Go" + ``` go + // Publish with sequence ID in URL path + http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", + strings.NewReader("Downloading file...")) + + // Update via URL path + http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain", + strings.NewReader("Download 50% ...")) + + // Or update using the X-Sequence-ID header + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", + strings.NewReader("Download complete")) + req.Header.Set("X-Sequence-ID", "my-download-123") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + # Publish with sequence ID + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..." + + # Update via URL path + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download 50% ..." + + # Or update using the X-Sequence-ID header + Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" ` + -Headers @{"X-Sequence-ID"="my-download-123"} -Body "Download complete" + ``` + +=== "Python" + ``` python + import requests + + # Publish with sequence ID + requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...") + + # Update via URL path + requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download 50% ...") + + # Or update using the X-Sequence-ID header + requests.post("https://ntfy.sh/mytopic", + headers={"X-Sequence-ID": "my-download-123"}, data="Download complete") + ``` + +=== "PHP" + ``` php-inline + // Publish with sequence ID + file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Downloading file...'] + ])); + + // Update via URL path + file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ + 'http' => ['method' => 'POST', 'content' => 'Download 50% ...'] + ])); + + // Or update using the X-Sequence-ID header + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => 'X-Sequence-ID: my-download-123', + 'content' => 'Download complete' + ] + ])); + ``` + +You can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when +[publishing as JSON](#publish-as-json) using the `sequence_id` field. + +If the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id` +field the response. A sequence of updates may look like this (first example from above): + +```json +{"id":"xE73Iyuabi","time":1673542291,"event":"message","topic":"mytopic","message":"Downloading file..."} +{"id":"yF84Jzvbcj","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download 50% ..."} +{"id":"zG95Kawdde","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download complete"} +``` + +### Clearing notifications +Clearing a notification means **marking it as read and dismissing it from the notification drawer**. + +To do this, send a PUT request to the `///clear` endpoint (or `///read` as an alias). +This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status +and dismiss the notification. + +=== "Command line (curl)" + ```bash + curl -X PUT ntfy.sh/mytopic/my-download-123/clear + ``` + +=== "HTTP" + ``` http + PUT /mytopic/my-download-123/clear HTTP/1.1 + Host: ntfy.sh + ``` + +=== "JavaScript" + ``` javascript + await fetch('https://ntfy.sh/mytopic/my-download-123/clear', { + method: 'PUT' + }); + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("PUT", "https://ntfy.sh/mytopic/my-download-123/clear", nil) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod -Method PUT -Uri "https://ntfy.sh/mytopic/my-download-123/clear" + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/mytopic/my-download-123/clear") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([ + 'http' => ['method' => 'PUT'] + ])); + ``` + +An example response from the server with the `message_clear` event may look like this: + +```json +{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"my-download-123"} +``` + +### Deleting notifications +Deleting a notification means **removing it from the notification drawer and from the client's database**. + +To do this, send a DELETE request to the `//` endpoint. This will emit a `message_delete` event +that is used by the clients (web app and Android app) to remove the notification entirely. + +=== "Command line (curl)" + ```bash + curl -X DELETE ntfy.sh/mytopic/my-download-123 + ``` + +=== "HTTP" + ``` http + DELETE /mytopic/my-download-123 HTTP/1.1 + Host: ntfy.sh + ``` + +=== "JavaScript" + ``` javascript + await fetch('https://ntfy.sh/mytopic/my-download-123', { + method: 'DELETE' + }); + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("DELETE", "https://ntfy.sh/mytopic/my-download-123", nil) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/my-download-123" + ``` + +=== "Python" + ``` python + requests.delete("https://ntfy.sh/mytopic/my-download-123") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([ + 'http' => ['method' => 'DELETE'] + ])); + ``` + +An example response from the server with the `message_delete` event may look like this: + +```json +{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"my-download-123"} +``` + +!!! info + Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will + reappear as a new message. + ## Authentication Depending on whether the server is configured to support [access control](config.md#access-control), some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. diff --git a/docs/releases.md b/docs/releases.md index 8b4bebb5..42411f5f 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1605,8 +1605,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) +* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka + [updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556), + [#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954), + thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation) * Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) -* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104), [#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911)) +* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104), + [#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), + thanks to [@wtf911](https://github.com/wtf911)) * Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554)) ### ntfy Android app v1.22.x (UNRELEASED) From 602f201bae53a7327f77860bbc0e21b259693572 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 19:15:10 -0500 Subject: [PATCH 117/548] Derp --- server/server.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/server/server.go b/server/server.go index 7274e686..f7a92935 100644 --- a/server/server.go +++ b/server/server.go @@ -969,15 +969,17 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * if s.config.WebPushPublicKey != "" { go s.publishToWebPushEndpoints(v, m) } - // Delete any existing scheduled message with the same sequence ID - deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID) - if err != nil { - return err - } - // Delete attachment files for deleted scheduled messages - if s.fileCache != nil && len(deletedIDs) > 0 { - if err := s.fileCache.Remove(deletedIDs...); err != nil { - logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages") + if event == messageDeleteEvent { + // Delete any existing scheduled message with the same sequence ID + deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID) + if err != nil { + return err + } + // Delete attachment files for deleted scheduled messages + if s.fileCache != nil && len(deletedIDs) > 0 { + if err := s.fileCache.Remove(deletedIDs...); err != nil { + logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages") + } } } // Add to message cache From 014b7355c5e5e95e75c18c89d4a30483b7486f24 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 19:24:01 -0500 Subject: [PATCH 118/548] Re-org --- docs/publish.md | 215 ++++++++++++++++++++++++------------------------ 1 file changed, 109 insertions(+), 106 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index cec5db27..beb0b924 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2397,8 +2397,11 @@ You can update or replace a scheduled message before it is delivered by publishi from the server and replaced with the new one. This is different from [updating notifications](#updating-notifications) after delivery, where both messages are kept in the cache. -This is particularly useful for implementing a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) — -a mechanism that triggers an alert if it's not periodically reset. For example, you could schedule a message to be +This is particularly useful for implementing a **watchdog that triggers when your script stops sending heartbeat messages**. +This mechanism is also called a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch). The idea is to have +a mechanism that triggers an alert if it's not periodically reset. + +For example, you could schedule a message to be delivered in 5 minutes, but continuously update it every minute to push the delivery time further into the future. If your script or system stops running, the message will eventually be delivered as an alert. @@ -2616,110 +2619,6 @@ scheduled message from the server so it will never be delivered, and emit a `mes ])); ``` -## Webhooks (publish via GET) -_Supported on:_ :material-android: :material-apple: :material-firefox: - -In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use -a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support. - -To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without -any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are -also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all -[supported parameters and headers](#list-of-all-parameters) for details. - -For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message -(aka trigger the webhook): - -=== "Command line (curl)" - ``` - curl ntfy.sh/mywebhook/trigger - ``` - -=== "ntfy CLI" - ``` - ntfy trigger mywebhook - ``` - -=== "HTTP" - ``` http - GET /mywebhook/trigger HTTP/1.1 - Host: ntfy.sh - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh/mywebhook/trigger') - ``` - -=== "Go" - ``` go - http.Get("https://ntfy.sh/mywebhook/trigger") - ``` - -=== "PowerShell" - ``` powershell - Invoke-RestMethod "ntfy.sh/mywebhook/trigger" - ``` - -=== "Python" - ``` python - requests.get("https://ntfy.sh/mywebhook/trigger") - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/mywebhook/trigger'); - ``` - -To add a custom message, simply append the `message=` URL parameter. And of course you can set the -[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well. -For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters). - -Here's an example with a custom message, tags and a priority: - -=== "Command line (curl)" - ``` - curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" - ``` - -=== "ntfy CLI" - ``` - ntfy publish \ - -p 5 --tags=warning,skull \ - mywebhook "Webhook triggered" - ``` - -=== "HTTP" - ``` http - GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1 - Host: ntfy.sh - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull') - ``` - -=== "Go" - ``` go - http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") - ``` - -=== "PowerShell" - ``` powershell - Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" - ``` - -=== "Python" - ``` python - requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); - ``` - ## Message templating _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -3497,6 +3396,110 @@ all the supported fields: | `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | | `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | +## Webhooks (publish via GET) +_Supported on:_ :material-android: :material-apple: :material-firefox: + +In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use +a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support. + +To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without +any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are +also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all +[supported parameters and headers](#list-of-all-parameters) for details. + +For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message +(aka trigger the webhook): + +=== "Command line (curl)" + ``` + curl ntfy.sh/mywebhook/trigger + ``` + +=== "ntfy CLI" + ``` + ntfy trigger mywebhook + ``` + +=== "HTTP" + ``` http + GET /mywebhook/trigger HTTP/1.1 + Host: ntfy.sh + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mywebhook/trigger') + ``` + +=== "Go" + ``` go + http.Get("https://ntfy.sh/mywebhook/trigger") + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod "ntfy.sh/mywebhook/trigger" + ``` + +=== "Python" + ``` python + requests.get("https://ntfy.sh/mywebhook/trigger") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mywebhook/trigger'); + ``` + +To add a custom message, simply append the `message=` URL parameter. And of course you can set the +[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well. +For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters). + +Here's an example with a custom message, tags and a priority: + +=== "Command line (curl)" + ``` + curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + -p 5 --tags=warning,skull \ + mywebhook "Webhook triggered" + ``` + +=== "HTTP" + ``` http + GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1 + Host: ntfy.sh + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull') + ``` + +=== "Go" + ``` go + http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + ``` + +=== "PowerShell" + ``` powershell + Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + ``` + +=== "Python" + ``` python + requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull") + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); + ``` + ## Updating + deleting notifications _Supported on:_ :material-android: :material-firefox: From 190d12cd54a9f61e2c638a90c37ad1a5fef6e674 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 19:41:34 -0500 Subject: [PATCH 119/548] Release banner --- docs/publish.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/publish.md b/docs/publish.md index beb0b924..824f1bb8 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2392,6 +2392,10 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim ### Updating scheduled notifications + +!!! info + **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. + You can update or replace a scheduled message before it is delivered by publishing a new message with the same [sequence ID](#updating-deleting-notifications). When you do this, the **original scheduled message is deleted** from the server and replaced with the new one. This is different from [updating notifications](#updating-notifications) @@ -2515,6 +2519,10 @@ Here's an example of a dead man's switch that sends an alert if the script stops ``` ### Canceling scheduled notifications + +!!! info + **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. + You can cancel a scheduled message before it is delivered by sending a DELETE request to the `//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers. From ed9c1bcb7881319cf7da1f934c8d8d36f08ccf35 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 19:46:14 -0500 Subject: [PATCH 120/548] Wording change --- docs/publish.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 824f1bb8..21f85546 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2402,12 +2402,10 @@ from the server and replaced with the new one. This is different from [updating after delivery, where both messages are kept in the cache. This is particularly useful for implementing a **watchdog that triggers when your script stops sending heartbeat messages**. -This mechanism is also called a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch). The idea is to have -a mechanism that triggers an alert if it's not periodically reset. +This mechanism is also called a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch). -For example, you could schedule a message to be -delivered in 5 minutes, but continuously update it every minute to push the delivery time further into the future. -If your script or system stops running, the message will eventually be delivered as an alert. +For example, you could schedule a message to be delivered in 5 minutes, but continuously update it every minute to push +the delivery time further into the future. If your script or system stops running, the message will eventually be delivered as an alert. Here's an example of a dead man's switch that sends an alert if the script stops running for more than 5 minutes: From de81865c2727936ac3bd007ca1a6c8669fb64466 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 20:11:48 -0500 Subject: [PATCH 121/548] Bump deps --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index be2bc07a..e0a8c3c7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3823,9 +3823,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "version": "1.0.30001765", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", + "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", "dev": true, "funding": [ { From 5ba1c71140a7381c5ace03da59ab04a7c688253d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 18 Jan 2026 21:30:12 -0500 Subject: [PATCH 122/548] Fix grouping issue with sequence ID --- web/public/sw.js | 46 ++++++++++++++++++++++---------- web/src/app/Notifier.js | 12 +++++---- web/src/app/notificationUtils.js | 12 +++++++-- web/src/components/hooks.js | 18 ++++++------- 4 files changed, 58 insertions(+), 30 deletions(-) diff --git a/web/public/sw.js b/web/public/sw.js index 6b298414..c8808145 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -4,7 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing"; import { NetworkFirst } from "workbox-strategies"; import { clientsClaim } from "workbox-core"; import { dbAsync } from "../src/app/db"; -import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils"; +import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; import { EVENT_MESSAGE, @@ -38,6 +38,13 @@ const handlePushMessage = async (data) => { console.log("[ServiceWorker] Message received", data); + // Look up subscription for baseUrl and topic + const subscription = await db.subscriptions.get(subscriptionId); + if (!subscription) { + console.log("[ServiceWorker] Subscription not found", subscriptionId); + return; + } + // Delete existing notification with same sequence ID (if any) const sequenceId = message.sequence_id || message.id; if (sequenceId) { @@ -65,10 +72,11 @@ const handlePushMessage = async (data) => { await self.registration.showNotification( ...toNotificationParams({ - subscriptionId, message, defaultTitle: message.topic, topicRoute: new URL(message.topic, self.location.origin).toString(), + baseUrl: subscription.baseUrl, + topic: subscription.topic, }) ); }; @@ -81,18 +89,23 @@ const handlePushMessageDelete = async (data) => { const db = await dbAsync(); console.log("[ServiceWorker] Deleting notification sequence", data); + // Look up subscription for baseUrl and topic + const subscription = await db.subscriptions.get(subscriptionId); + if (!subscription) { + console.log("[ServiceWorker] Subscription not found", subscriptionId); + return; + } + // Delete notification with the same sequence_id const sequenceId = message.sequence_id; if (sequenceId) { await db.notifications.where({ subscriptionId, sequenceId }).delete(); } - // Close browser notification with matching tag - const tag = message.sequence_id || message.id; - if (tag) { - const notifications = await self.registration.getNotifications({ tag }); - notifications.forEach((notification) => notification.close()); - } + // Close browser notification with matching tag (scoped by topic) + const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id); + const notifications = await self.registration.getNotifications({ tag }); + notifications.forEach((notification) => notification.close()); // Update subscription last message id (for ?since=... queries) await db.subscriptions.update(subscriptionId, { @@ -108,18 +121,23 @@ const handlePushMessageClear = async (data) => { const db = await dbAsync(); console.log("[ServiceWorker] Marking notification as read", data); + // Look up subscription for baseUrl and topic + const subscription = await db.subscriptions.get(subscriptionId); + if (!subscription) { + console.log("[ServiceWorker] Subscription not found", subscriptionId); + return; + } + // Mark notification as read (set new = 0) const sequenceId = message.sequence_id; if (sequenceId) { await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); } - // Close browser notification with matching tag - const tag = message.sequence_id || message.id; - if (tag) { - const notifications = await self.registration.getNotifications({ tag }); - notifications.forEach((notification) => notification.close()); - } + // Close browser notification with matching tag (scoped by topic) + const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id); + const notifications = await self.registration.getNotifications({ tag }); + notifications.forEach((notification) => notification.close()); // Update subscription last message id (for ?since=... queries) await db.subscriptions.update(subscriptionId, { diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index f6e47a7c..908df469 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -1,5 +1,5 @@ import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils"; -import { toNotificationParams } from "./notificationUtils"; +import { notificationTag, toNotificationParams } from "./notificationUtils"; import prefs from "./Prefs"; import routes from "../components/routes"; @@ -23,21 +23,23 @@ class Notifier { const registration = await this.serviceWorkerRegistration(); await registration.showNotification( ...toNotificationParams({ - subscriptionId: subscription.id, message: notification, defaultTitle, topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), + baseUrl: subscription.baseUrl, + topic: subscription.topic, }) ); } - async cancel(notification) { + async cancel(subscription, notification) { if (!this.supported()) { return; } try { - const tag = notification.sequence_id || notification.id; - console.log(`[Notifier] Cancelling notification with ${tag}`); + const sequenceId = notification.sequence_id || notification.id; + const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId); + console.log(`[Notifier] Cancelling notification with tag ${tag}`); const registration = await this.serviceWorkerRegistration(); const notifications = await registration.getNotifications({ tag }); notifications.forEach((n) => n.close()); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index a9b8f8ff..a3025f67 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -50,8 +50,16 @@ export const isImage = (attachment) => { export const icon = "/static/images/ntfy.png"; export const badge = "/static/images/mask-icon.svg"; -export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => { +/** + * Computes a unique notification tag scoped by baseUrl, topic, and sequence ID. + * This ensures notifications from different topics with the same sequence ID don't collide. + */ +export const notificationTag = (baseUrl, topic, sequenceId) => `${baseUrl}/${topic}/${sequenceId}`; + +export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUrl, topic }) => { const image = isImage(message.attachment) ? message.attachment.url : undefined; + const sequenceId = message.sequence_id || message.id; + const tag = notificationTag(baseUrl, topic, sequenceId); // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API return [ @@ -62,7 +70,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => { icon, image, timestamp: message.time * 1000, - tag: message.sequence_id || message.id, // Update notification if there is a sequence ID + tag, // Scoped by baseUrl/topic/sequenceId to avoid cross-topic collisions renotify: true, silent: false, // This is used by the notification onclick event diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index a9268dd2..1b4a78b7 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -51,7 +51,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop } }; - const handleNotification = async (subscriptionId, notification) => { + const handleNotification = async (subscription, notification) => { // This logic is (partially) duplicated in // - Android: SubscriberService::onNotificationReceived() // - Android: FirebaseService::onMessageReceived() @@ -59,20 +59,20 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { - await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); - await notifier.cancel(notification); + await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id); + await notifier.cancel(subscription, notification); } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) { - await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); - await notifier.cancel(notification); + await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id); + await notifier.cancel(subscription, notification); } else { // Regular message: delete existing and add new const sequenceId = notification.sequence_id || notification.id; if (sequenceId) { - await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); + await subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId); } - const added = await subscriptionManager.addNotification(subscriptionId, notification); + const added = await subscriptionManager.addNotification(subscription.id, notification); if (added) { - await subscriptionManager.notify(subscriptionId, notification); + await subscriptionManager.notify(subscription.id, notification); } } }; @@ -89,7 +89,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop if (subscription.internal) { await handleInternalMessage(message); } else { - await handleNotification(subscriptionId, message); + await handleNotification(subscription, message); } }; From 4b474a89b7803e8f974f888021612c2c49cce734 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 19 Jan 2026 18:29:45 -0500 Subject: [PATCH 123/548] Docs --- docs/install.md | 60 ++++++++++++++++++++++++------------------------ docs/releases.md | 40 ++++++++++++++++++-------------- 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/docs/install.md b/docs/install.md index 0711fdb2..1c23dc30 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,37 +30,37 @@ deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz - tar zxvf ntfy_2.15.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz + tar zxvf ntfy_2.16.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.16.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz - tar zxvf ntfy_2.15.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz + tar zxvf ntfy_2.16.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.16.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz - tar zxvf ntfy_2.15.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz + tar zxvf ntfy_2.16.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.16.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz - tar zxvf ntfy_2.15.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz + tar zxvf ntfy_2.16.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.16.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -116,7 +116,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -124,7 +124,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -132,7 +132,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -140,7 +140,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -150,28 +150,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -201,18 +201,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos. ## 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.15.0/ntfy_2.15.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz -tar zxvf ntfy_2.15.0_darwin_all.tar.gz -sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_darwin_all.tar.gz > ntfy_2.16.0_darwin_all.tar.gz +tar zxvf ntfy_2.16.0_darwin_all.tar.gz +sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.16.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -231,7 +231,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.15.0/ntfy_2.15.0_windows_amd64.zip), +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.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` diff --git a/docs/releases.md b/docs/releases.md index 42411f5f..97302aca 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,34 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.15.0 | Nov 16, 2025 | +| ntfy server | v2.16.0 | Jan 19, 2026 | | ntfy Android app | v1.21.1 | Jan 6, 2025 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy server v2.16.0 +Released January 19, 2026 + +This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications, +custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app. + +This one is very exciting, as it brings a lot of highly requested features to ntfy. + +**Features:** + +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), + [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) +* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka + [updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556), + [#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954), + thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation) +* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) +* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104), + [#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), + thanks to [@wtf911](https://github.com/wtf911)) +* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554)) + ## ntfy Android app v1.21.1 Released January 6, 2026 @@ -1599,22 +1621,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.16.x (UNRELEASED) - -**Features:** - -* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), - [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) -* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka - [updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556), - [#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954), - thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation) -* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) -* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104), - [#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), - thanks to [@wtf911](https://github.com/wtf911)) -* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554)) - ### ntfy Android app v1.22.x (UNRELEASED) **Features:** From f4fe62bd919fbaed2da4fde64695ca849b3cd993 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 19 Jan 2026 20:29:28 -0500 Subject: [PATCH 124/548] Logo update --- docs/publish.md | 10 ++-------- web/public/static/images/ntfy.png | Bin 3627 -> 11196 bytes web/public/static/images/pwa-192x192.png | Bin 6614 -> 11196 bytes web/public/static/images/pwa-512x512.png | Bin 19716 -> 38318 bytes 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 21f85546..e59e5762 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1,6 +1,6 @@ # Publishing -Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](install.md). Topics are created on the fly by -subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick +Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md). +Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Here's an example showing how to publish a simple message using a POST request: @@ -2393,9 +2393,6 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim ### Updating scheduled notifications -!!! info - **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. - You can update or replace a scheduled message before it is delivered by publishing a new message with the same [sequence ID](#updating-deleting-notifications). When you do this, the **original scheduled message is deleted** from the server and replaced with the new one. This is different from [updating notifications](#updating-notifications) @@ -2518,9 +2515,6 @@ Here's an example of a dead man's switch that sends an alert if the script stops ### Canceling scheduled notifications -!!! info - **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. - You can cancel a scheduled message before it is delivered by sending a DELETE request to the `//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers. diff --git a/web/public/static/images/ntfy.png b/web/public/static/images/ntfy.png index 6b969a846efc6c3dac70510793c72253e04389c9..62e8a3b956d182ac5da5c8d8737ff3c53c76c610 100644 GIT binary patch literal 11196 zcmYMabyQT}_dh&CH%LnjDk0q=H3%po44u-_Awz@25E2ps1}z~iDIgsK42YC;N(~_b z0z=Ng4DihR^IgC7{BiGD>z=rK@3Z6VvtJ4Nx|-A!Y!m*KLCK{_P+->$ioc5f4uAe#N6Kq>g*q6@9P8z3JMZ+^YZX>u=jQnh5EYW z9xAZ`0NemAjmO5ppAYh0dvna^t-Lp0?zHiVOY7l&T}*Tt~rVusUY+SWsMZ56eV`yz>-o+aLYoXFUl z3jn6cli87EWgp@u=M_VpLqbAG>~1Tc`NPpyT~IjsHT(urPy&ZXh_Urk0a$8NokaCg zTu936%HzxUr5?2H>eQyqyCe~ZH@p75RI?GwD*NN5Vcc{_PVYs`L%Hvb;U@{-)@$y# zJ$9o1!my>5{iOcwn(mSX?}f=6%RkYKc^0jry8_AvI_@D7H68>0_YMBKyAPe0SvF`- ziL^0|y;|&^OR0uuxr!J-fth7|Q&1|~LwQ(0#p&6Ct7fw~N}BwecY*{?<@b`sonAcv zqmH@|;~#0j5V_*f@{9(lwdV8bB%v2eWwOmd@1O_&vKJOsWwyn>H9y_+A!22PFLfTB zWY-d!iJm3zG`Kxa@t?GOhh#ZnCxX4sFQL&5^tdn65@2vN%bApEz0zC}G-d0JVO}K4 z8XDgf%YRhAl|JGUs_9&o6CNrOS72dPfjYfZ&i<3F*luRk))F-4?I&qrOi4EGt^)kw zySR1BDc|frJ|H#H5F4hp+9H9Q>Srf|g~*id4&Y}{g;zkQOD`NN3z?0&W<3c;w|36T z^*HuIw@=zi{RX0GDz0SXY;%I&^y%@-?#^joo~@N7jU_B`+;Nu#Uf6J$<36^~)a7N3 z$+S_o-AasUvvkd}!tULYL=|LA=je=u+g)Dyp@l7^iiQ%+?7!$nQhwarJ>pnwL5^1=G zasI23d(U=5j*G+DNmn}@$KF;N^O~L$;fE~Irf`~fp{KH)Ql7Q=1~P@gs3NGqdFB)_ zg(J>9F0+-_exGz1}KG*4w`KpSz}K_oDSEtyKI(q$?jGsb<3IUg&&D!_EDU z_sS6+-BsbW7J3AvV#Qz)p|Z)_&QtV{@Y?_1zV*LA95J$v?@X<6$u~@*1P;xI$WhBf za+zq`t_Jh}pF;eK^(}R_mbi_HT_+Vw;6n*u$~&6o>w@V)Q|+yO6qYd+>B3MPw^q+0 zMqB;p^|gD|_}m`3{zK*=bSjs*OBUaqz(;Ym7XKrU1qZO};Q^~egsJyRr#C(1?`bSg z5r#|pb@9d(*t|ocM|K$Y$>ATt3c0Uf_WGO@TKqi|XO7c#<6W~X2)()=DD3uW!aD%m z;r3{F(ZtMm8q!cSMUE=oBsCjnqDD&_;`Ih@t5UG=3`MBgE8Vmsnr|f{B&rkH$S^Mn zrLjG{_)3e5Ou`F64Mqjt1Hv7yjm$bkwM5g%8P?m3pcmQQ=L0Bh1_ul~GmH*UrUiTL zWJg>upz}ZNe^8u(PDQ5vk4*NvFqe^HhGON+Mk60Zw-+$|E!MGY>VO?9}gSs2=%bYU)G#OAOPc1kTkT z16-e(!E9u!*^vGt_jbg*jPG3*yYoNQEI^|OBkgmx`e)49?eroy=l_x>I$%oi4v~@o z#*f_En$_*e2k;L*RfwIEz>U77f){+gBnAVhblwF4B4s6VwW;*L-gqlQ zTxMr{#aXE-%K0=co-K;U*}exMQZFgsjUfVx>64-mh%?W2#eZ%E90xk(gaKGT=MB56 zPhSwdMY=FMV6L}DBQQX_;6T1&>T#V?2aE$|x1Svdi?s6>&79;rZ4~fn{J+VRrnQe?_}yZ6BYgVkAeM ztvEkL9b((N! zWt{Ndcys>}0_(eT4lJTUy#X{1MCWJ8Bwlq!tx4YtFz6o&jQiYEJ#m_((*Au%jxZQD zZJo824=Uu#5-yl1G-pT_{}?@%`GOir8u@5?f+6&UQfCWwq~6irg{Ub z4m7#?Qo3cjUk%J*oOAf{m2ec@*H>Ig)N0FcZX%5bGmhRVB!U3M8<55oL+1@j%*zWQ zRd6|vSM+|ed%;`H9Vpt{GlPAXD0}l;9lT8pwDJ9mxJZo>c7Vx4A!PT<_ z*IV`_mGu`p$NwSLfp#U3|7wZZ;!Bzoxz5hB^=m%6Lm=5*7+_+L1il-iqN+BHTIg6i z`fch-If9o0E)~3Y!yTt}%bKzB^!~CZkM)_UHiCAc+-86KGB17nCiNBcqdJvKiPA#j$!A7?3io4 zfY$Ff<>&&jU&%=m;!V##S-^6#dd=J%8xm6aM02M#pjlG^)%*W`U`=23B{tJoAi4v8 zC(e5Pqg5x20{&Ha>la#lUA4e-(J<{#hGIZ5a7&IK4fsV|rQ~(L8uoOTjlVq`DX>4v zHu6eucpOkC==-|=mxHkui3MP+=4nYT3R^e9b9OI0$y*f?W0UEA;{T~iivq4T9o2h^ z*|m&E4UM%Rku^ugjr{dx8uV3)9X_xPHdQJ>d(hs;wo(PF@gv6LT0#vRPo^8_q*>(P z^i`%D1K$ne6#QdVihrQFtvTlPj=P|8g`rWemXg=Xxg79`-N^mh2P7uh+4s@PGZd!KenU4mCGDzQbQldDFU0~Y z0)J}R^DunLjZvxt(DEKkG8GE&+w2iMqOT7RE|GQuR#p)7T5k zy?O5GFfN%@<`yr{7r^{8#*Kb^7U_;81=QrD5Z`H{(iifh80doD@;;V7ldAHJA0jcJ zfqRnnTtpc%967XpIX!MKmAO1u7E{XN>oXO6zvCvz`h=s?*TBZq{$Mvbpqcc7Sfb~~ zK&M4OQRW3FE^<6`BZ>`gP(NHPi~8a%emE-~-6w>|tY?VYR4qgixK0sV6qV3!p-w-h z+0c|FkP-I>3eEE5rFGgZVYP#j?80P)5>fU3eK!K`A;?swd%raav27-Qip+_APYKj{xW zSkResCHYDJBR+BC<^zhMGLddQXtWEpok#xRylq)99`uc0b+fag*^pA;usjpDE`|&c zn*EO2xKV}gwT5tD2JTR>|K|Z{B1+={?TsU-cHIoo6yYWi{RAe27p1LSqrvdrNzf|y z#m6;PRNou7O4&p7@6HnPPVWSnND!)nW?~}WwkYb5NGtieH1uc?p?Ih^tg<5<`NK?@ z_$@kH$#u$LO9<%q37NYkedkN$f>-JPm=iN&FmR>tYKkv-SVt(WfU8hCgnA6GQwDX@ z>M7$`ReVohO-K`Te|(UH4S&2@C0v^DP$rfgJ#F%Aopz|`1V_WNLG+dp7X93TTaDny zb@O?1^QJa>HT4K~;@9eNPGa_y1KpUzQ#?LfK1>PYdCLZiEC9Sm*tThr{;=LwZeN-c zJ?vduzSRobjv}qM2xhjxo1C7|F8lqFPn^1{Xfay&nmkJJZ*f9A+}Q`d7IW4T(nI^q zHsyC#zU>Rrl{-%T3z8jOP?11bn23!+IKkR7oIR6&u-9}DtHUL2zq32~A%_UF8<^#| zd+&=9L`SBR#HY&&($ESnJk9*8|B3t#tS1|A9$i2rPp*M^9QkdtA$YDu7l5u^iw>$= zeCv|ZmBLSZ1N`Xss4S97+Goct)#k-VW44_DUQ4WdrR_3k;ym*e5<#mEjeSc4gGzWu z5X-OGrtk)E7A(O?^I;eXrS?;=ebZMUsQ-ih>ko~C$u@0ekq_9v>;#R5N-)WjQL?m~u#8va>%7uqnwZC*SsacP*qCA8 z@(c`;t=rDOe@Cu$?-Z;nf^PkmJG>grnwCWrvYA{r>feW&c5~j}8X|2M#t^Sx%Ulj# z&(erOn_@xh&^U${?&6qTwvl((s%&6$&Edbk%-g@km@7hBhu`#vcb#Wrj9otr8w6V~ z_Nr4vBxpTkvQjfDY>mk8rPXKY+arwFRyJ*rs^BCKPy%>bHxSr{uHVC*htt|wSn}^N zqfa;cReySHbb+f93K~v)tMNRGhbppRfLLMu>vh^-!S6+_;AJ;eKqqvCdAZSBlXAIvi4})CIM9~|N_bQc{*f^-LICy;6 z-2ef_gfB`&@=8;KRy#S@ZZR&$6Cxd*@mONW3`m#Z4n-?dr1sgcWJlFMiwF#_CIJ}2 zG;nRgNnY~plXXxUyP`~BS3Q!&LlCO}I1;{ior?KW)ghrQXBm$8B*SlnIn4{&Bn4qJ zF{nQW&s^IJ)%Tc~Y|H{tX?CO8{Gg z9$s0r`M?j}`^753{DVO^|Gq-kHRI9t&H7|>wSDb5md9p6*An@dzwn;Wc~8TBg4LTO z2SeDhn5qDk%F*@i7QhFb*Xz2G!eMe!S6%cJ_2ywM;m;3j1E&lyx{BxI*aG80N6~m_ z_m!g3$(h_S?ilfq?P)-?yyap&>+SI>?PkR4E%$1@V{9s4hWuTezlw1cH3M3RhLXvA z21WQ`A0Wy5jsR{3Udbnb6*Q9rpO)2aLTzDpQg8nK#;o)tR37PU9F*ldVfIK>5^k^f zL5Q4RUb^o}z&vlZGn~E)h0cRuL+^X^jQoAz59^jg5W!g^JAtk)O;PJ;w-*aiAw~Lm zy(6vSKGe6DE$|+e0<4Nnhrg_Uf?`HZpPUL#e6{W6yQ%%*`>X=EzIAE4WxYx)Zx8wD zP%RfMGJ@Ngt7m+bY7ir;n?wLROgmh#ELO)%iA6-i!)~-V?pFSrg_R0_aWntqmXVya zh7ehAZ}~L8-389Gs%o{JkuF##&1z9UJ)hx%t#JytG2%Y62-U=TYppVC)>-|4Uk;X| zFwlI&V*RAxnZRtjKm)l}t4PUz5=zRcY$ijJ#{B-0_s?~`?t&@}0N2BAudP9^SNskc zZ@h1$SK&x!rS?rx;a4@dO2bNydp4vKMn+- z4>-jEk(r#U7Rp8OLUKR;JYg39+oDZ8uKue8t)aO(#Hoi zYg2MCZJ0j9=|#eU`}qa^p>ylo^}69KE-ShCaBNlT!y`k%arFuY^) zf`_D$CY(vf*_=U}${w<&$|aHz-^k~$t(UMu>pR0w+0gOg%Ld)GMj>p*53PkTggP{A zn5i7H%>ECB(eUz8>eS^Bew9`q_A?4F(s)UJf>D#(hN0`1NFr)l`V(>Ti;sW|y=5_* zHx{g2hP&(Dzzzr6s+TPdW#EE*0!F6Do&Hhmlg`HjMJZWa`! zZNI}`izL;w88{j*@D2D06xFLliwqs!LRyTL7jXz4;en- z?*e-A0Ou&&W5wq)hFZ&Tzf1S9V{5ei%0|9w4c4MLZE*aGcB6dIX0RHmG6rYf!Mg79WgixMy{V{#($^4E8*-9~hnj5pwoc9F*B?!J;76{ux*;sX}7^pGG+dL1o7SjfTbIo^J!PPE6 zN9q|8SY$g5UDHkrb9CU{I!HUAmyJm604G~}BtOkz@7F2>Me1l2U(oD@L7<6)NRDJr1p82 zU^%0PXJ^QK;9GmO$(r{eC@_JE^JE`wQ9F&kHs%fotW4Ae0?c6?2FS`}B2rVk<91R& zKdKGVf;-YSU{Q~WamyrM`ma3^AC5m5?-vW0!!>EaQU4TF$CSYyCJ^FMj>~YBbo1Va z8v`xa;}t|ikA`TKt7vCyLF9To=+b{cVf)Xt*WHV@H5}uUX**eAxkK6qTgeo#NI1U-t-%zzWs*wSo(ZBwQmnOZ)X2;{}{Y1 z-Mn#E0?#+=>m96<{@n-2#Dj%T&z>Xe`mkMrmmK-Q*G`|g*zILM^@=I;e5x4|JA?v? z!4G^>sS1EcuZL~3OF4764?eoB072g^+&5rx`Ls5GJK!Z&G9uEez zTnf2Vb0 zcuqC%)^e69(7@mi(o)Ofl<1!E!9ByA%1f#|+Z+we^&|zMA*m!ccAR|FQar2bOwLvX zW60fNQk2f^z0teG-}FyW+5mkT`8qA{0z-)XvNovMgbRJTA7v?;&G^TmbBdgf3CGiO zPn9C#t_=&oIf+;)2uGK%(6AQLL>~TZ$A4r!^McR7mHt;v6~7~*SMT~rWEhyKm_Uav zkHd#$ayrN;v=_QUWrjQj^x63A`)55aJyMRK`0p&7tYho)y%MV*Q-F>ap85Q=tRiH> z{c6---!G4m@!Anx9~@alaO0{BcNisdbsVp&{Jz|J+d??l8yLBS7`phd67vS0{2;Bm z8h79HL6)-l2f{+K2sdv+Z97v$JgCq%nJIgxZYoP$eKalZZnVnH!(PNVXsY)}*pj7K zYm%n?>zz$lwh29YFB@y8Du%_peLBiul;I}ou4v8+s~!5`D)FM zf#B_jM3Gb9W$Vh!g_Y3E1K*d^o`RJusBa>_t^<_))t*EK;$vo zUJ_}1<%(QkH=*>1nb`_gY6#OKesG!Cx?fX z3RhV!kGG6`D|L;ZHom^TF5HtOi(hPKiic)~Bl!#SntIu}`zotGpUg zP?SwqRKb7$FF0pfPh+tr?}nvK{30TFb1G@$@L5rcmdc0uR#fBpF<0I%!$`hao<1>* zdLKHfb4#rY4)1IQCu`~z7%>kh*C?%R{47ygu(_ZQnzM%Pf1a%F{CwKKH$vq2}b$D%)<{vFx&N!d1GHS zNvF#|6W#lfPsqUQI+3pl;0w);cCA;$m=#`d-G~_iA$FMLuhu~QE#GvB6BqNz&F6t)&TYZjT`1Aw_N?{LWA(j)F`HEgkv z`1B+JR$P7HY78c(%;=FTh|Di*NV@XUBg>zZZZZ0nkbKjdz> zHOPB-*BAcSL|*SpGci573hKN0JKf{?KKb%glg`AJ%jTkR_56zbbzh2o4gUOAr!htO zjvU_k!bg$})u9cu$*hZu$ERI)U8A2ZO+W95nKeFYEnp~~;7P@jN0Q2UYFfswnae*? z;4yU;qeo%ya^WJe>Cf5^E7n`y6jXX8=;QZ;@Bj8Aw$l;MT9vsHwZPYEHOR0}GWQ{L zDnP0AhB@pTltHj*t>6(-W{-pX^EID8D3%he3cp*+h01iD(tDQHTHs2Q%PiLWVeVoI zTexH!Wf6H(nH791txiD(nF#Xl|EgG^vw^Csp|SZ*Ry-|`~Gs1Ba?!WTm1+6 zjInWhZ(k0L`Bz=^7`Lc2`JsTsr|!@pAxT#okPw!2~8S* zq+K0gjtf6cU-3>A&SANUyb*eIGGRI5h>0R}F#|Ve5nr(^^Dr zcfemax2lT#g*}fah@Q-dbH5}~NF%abFV=moy)5t=@0xiF0C#@iAPK{LAIDa-j}J}u zeXxl#&-gzuy<)fATxhvC_+c(fS;XMlj1J<9A_i}*cbMf?#e>o~f$skd- z7DC+@<#H=83@#Vg#xL?8B@}Qh(iVBlG1;nB7e_u-eftzbqAE+-7nW>hlwNtg$Q6As za^-lpUc^pO!T(Ogb>FzEG{L*zda#c&7$_~s(XbhDM5G^#B3#xy`5gOjonO4ZUJ)lI z`(v*4;-tqi7&lO0nQ!~x!gdd2A3=4BqJvx$)tMAZVt~kliOF6lys92+1X7~NT}BR5DceO{dXB$-S@#cdBV=RCIEm^`9Cax zGeNK)il7^}A(fRi7X3Ge>EbUVE{Em9yc{fW1UjL$$!@cKn=o~%2#;ZLV%to5SRd=* zH$XsRqm)#4aLGhcr?-Gixh9;yxG7Wztx!hU-O>fJPPHm(5LcbN&d_i~;Jr_Q47e;Zjhj#u~3aeaQ(JZ>%2{%HSfZrcI+P9H; zaMRDwSY`Z8z3xwhHTj(T-^)DVW2vuA6rru1e;g?k1dfaOG=$hlM3b!tx*PeAN0Ha9 z(y%d=;i*vdgfm@5vVbA1X@f}P3!2$}xH&m@m^$%&;b4!_5^`+ul3A!;-ZzrwouH{@ zP`fUfhWt466~c7k-DBRS?J7mASoibf=FO z%^wh3T}YJ^%|pL@-gBXJn3H~}Hh%c#+dLD#yr=3!7OvxBc1S%StKDlG@<9+8^nhvS zf*6}jqZ*|Lz$$@D^4F3sm4}+%z!O{tG@j2_lheK53G&p2I_XiTJA7Qcdq!*BUzY-p zTI)XNqnL526|QsKpIU#-uDap#_eE8#ZQi(}F%W(PFzgZ`D2?Mfw>ZkVlrt0O_BIk2}tB!KQi4TPid~zUz!^pUNvM#ANDnAsV3^J)_IjD+%9} z-Z^{RL(vRL<8)mV^8_^{l^y*Vmqy=A2S zHCLF|&htVyD}1fpNhY2GJa5(zZ(-%49{(ujc^w13t{H8r^PNBUPn}3)FU)6P^@-w>k8hw($JgT?6d)MD6~gz2zH4{x-%x_b)VscT&>W#Pk*krCid^={ z24><(XE2To7LKE4EwhK}s!FdMXjF;3J3;+w-;Z7}az8|7V&v3l=e~ail3SlSMH)ijARqg5GJE8dWK!%fRD6reOMK~C<7?$cm z5!jt7a1bL}wN%d5M+;C+(2=c|p3d2p>O)wpg& zm}u%Fg!(egxms8VU0YWQ(u}zrYZzIu`qRaB*YCzK34kx!zOH#8Ti6e2Y?jcCix z$myXqS9A>Z_uz)m(Pi}$NmQqn(L)HS7$&HKjbgf(`b}w2*W~ZAxd&2!M!N=5Jexc&X@H&X3TYDu-HFOqd6uHp;P zYkFRUy|e)KsaFh<%E0Ep3te{&IqGTd6Y4gS=pH??P}!f#v#x#qNE7LCfC#zA@}+8) z2vah^QN;?0M>jC{#eyG8JE*r`E7)PD|7bL{et9*S6{cPr6`2p>Ud?8a8#>uIEcVVJ z+4nCypKs5Lf*Jn`4UY>wT_ZY0s<*g;Z==A}sA1!nFab9lX&@6`pX_G%+?2wQ4j=r`}qw%gGwTnv3yDdC%xP3 zVoxdHdfn~2|8GS7SvI;!4BijL!$3?#I2UV@LY`jww^XviZ1kxj-ry^kmXdkJBSvEu z-hAOAh(WVef6>xOy~IV60n};qOUw=sr&YuPo>>gios01&ICFgAd}P`Fhrq~z3M}?8 zE`qCvmTq~v{vU2bWBPOT-R-2;Dy=WC*FX4=_*21+^{H-^qTq_}^It=Mf4KKh=Mj)z zLk`^Jyzd5i(6>MR663TefingMRNd*TyYmv}e>~j6py9Y6v1Zoz%Ka$k>SO8tuc2JL z@-#SLz7p)2K8OJU4n1r`0>j(Ghc#<%i2#vUtgKD<*J5ntgenz0j}@WEq~F3Ofn%RO zFuSdAxa5A0hxQZ#gZ&RKT3Gm}AGJiW!W1Rq3Iq$iWL{zvKSCDu0_wtlYu0!;b>%hl zW(&gXr&De+G~7kw)`n7$`8CpC!;r-FZ$I7`V z@P#vKi%hTx%C-dA-kRllf#))wkNrR?;)S@X=8tM|O=ufG^OSGr1>jVB$N_+=7fUWT z?x(eKp;Kd061e{x6~voU25X@vG$r%8%&> zDxUfllKt!bV20PD%J#u>EA3CB8&Ef8d!+{F-`VFiNqhxu!7s0?4RrcZZ}{IpQjnQj zT6<|d1@~VZzezI9hnab_Y-C&ySMQOpFXXKV)z*6LX2JGw_2)Zf`~$RrK@=WJbIKF& zY~427JsV>_zc-CDvYut#Jvv%rH+4$kY?W=ruw?4i=8cZaSk(06hgY5O1b8=Ce&1RS z=N$R9GCQXjomRug001<^cHZFDcbTwt);1Y(i6K<*?}d^Aacfs^SANYM_R{*-wr1UL zM~ca2;h}=^vPM7m#opQcU6Bg=00Jz2P{>_UL!%Mur1@IFjfIPUhx8~79L(N|uFts$WvyM8CB)J;Foh*;xjG(U0&JFYEn8I zPB7sNU>(*iCH0$^GIMUso@~n=brsG0l^xsv*YH0^sW9>LvwP1d}@|8@q2nJNgQt)-~ol-j9iEf~dCzP3X| zB1EGQNJ2s|VVC{9{jp!?yyu*|_wKz5B-v*sxx0Jreb0N|=e+N8&wt(ByFd{|6j8*u z1RG~|#|sQ62fGzKMz%$W#MmOqNf3D=RFB>0V?04cf}weAihKgb6X5CU>bi2?ym=2X z#u^!8v-7Yw5fF+-qsK=_NB>n*Q}YjGgP%Z_4EpC97yu6dZ#W!&B9TZ88AS+4BofhR zH2Pj=XXiBlrUEDj;C};93ZNtvKhtEelPL@U8yy|Jsj{+i8-S{OSe-;L!r}0?va+%V zk<6F`fH>6IBfw*^0|tOaqtVAID=Xhbkq|3jKA-RV@p$~uHXGHa3>^^z`thE0HpoDPLxp;sM|>4C54lnYx|22TtPWuWZKI z^Py}Y(u3s!mfUbOux!AD!Rl;CZ6-D>+ZX%FXtP6pG6Y_B!{C@v6NS+p_HAUh;M+z@KrMeD+kubUk`lSJZ1dK0DG+>PJ`^hAU z0aFmc%zx}N-UDv7^9S+|i61GuqJ3fm!mc=U`V`DiUS3{@R270K_8C}NJSwJ465&`3 z1~a69k&44*gg#(&Rx?e&#I_t-#G5BX^JOt(pK?y^Q$Hks8N!Jea=-_1(o2S|lrIZV z2Ta%s#>9ZpG9aBCXv!j;th})E0i&5ActqP&I)4J%2DN?bV2Zzt5i?S9_QHf4-LIcJh zAqA7z;D-b<7ENaGI^ved18$WsO~6twnpJ zt=NB}*Pcnnu(V-5Hs1LletP{9On;kFmTs`FBg8rNdo0`_q&AB>VpbOOKrT{DKgq{v zNl;vWVqo}iun&*!_%nXF<1aWeXntv%!dFs?d#?LBUbyQgxaYdB!RIY;s3W2>2yH?| zw5KC(euTksrf7A91Pox<*Y_z_ZrhCCyty5{BSW_OndU3Q!(Uy3jU6j+Yk%VczDt`r zLfTx=5%=gw!nN`x)p^>M+8PXa=aVCN=;e)g@~zi!a&*L2S2dMY_|*-|F0hU;VL)0P zQJh}q14lw{rX$LLAsa9Q2LAoY5xn>LF?_dqA?|N!!}PKWd%csYSvVcx?CZ*+ON0Qf zE*{g*#Z>19Bn_4^MB{P1`hVd8+_&{vJiosa=OSTy-ODw_nT{~Jw>2Hf&I{+%xLUqc zIwIHkR0|2%`Dhd`2L6NlUVaYS4j-aON?Av6F7(oWWCqJL0i*Rk z-B#l9VCmd1*-{X@6+Oibd_1Q#IXU z-&yN4U>8S6++)T0iXMYMQ6#W$j{+MjR7LZ@Z{E3}TdwN`QAG7G7% z7IkFd>^eMr{SsU=>q^^Q4hF}FeMOzmxZ{G3Xs(;-?K^{GQtawT;|xC@Sk#844fAdH zH5LhDOV?h!b|j$C5r1)3yQ(AE;Su}9hTu>BaG`uz(~EK28V66m>qe$x}zfVK~$gsf_0j=VxD;C#io59a-8i-+wkC`%iv`XZLsF*qK2o zVAgenPXW;}w{^t5^}I{v%hGzpB0f5O3LEx!BG7+AnHlzUg!7SqrqdDTP)A(i#ktU% z1k6ry^q>0zn-A{6n>~lY2h4mul6Arr9U+I>(h6ghR(I~u>xeuf9Kp4-FRCM%HVs0t zD7JR(!^>TJ5sJmgab&Qx&Z~%K3|{6zsmJSZJTgkXUQ0T{Tq4J%#Us=@!ekd?i3I+6 z^bj^5d>>~*=ahrZsUsQts<#XZ2fdESWzh+8lEN6%et&G05n&W+Dh(Kb_doB!`VV&D z#OR1xG#%?mp<$*Y?hpdFx_HzF?0@~o@z+Cpad_~gB)l{;WF6t{>xH+_U-P%FBbP|| z0(3f(eEj6we-oPot;nS#)IM)s7T$tF@Ru*U@>ra}@rUu~BdsW>Q=QwN35V--#Qq29 z3I&mTsef!;O65zdBaH0Fy=F&8ptCPF;&gBf`}#lEc_|-4U(m=xe`qYHj$BeJdNbkW zKM&@vj?lK`?gs{r;|OA%8Y?L=!O4KC!{u*JPu$>srJH@3Fv{ zJb~yqouDA!PvIKL%uhX$Qe811O~5D-dizk@C)pxhXoUDA+ooENaDKRyC4gIBg`k&5 zQm^p?qBEC2LTWS95lz6zv=vGYJzJOFV-Xv0fUFBu6{~XSrKR<{Zx59O$d?h!eyl4UjG9W(F+}9B)q(Ts7 z`!uB>wn+&omfgCkRcM=44>Jr84<98jNyIq(EU{FJKpmY7Ix@4o0xi{7;NE#(fzNAx zm3sa9^*bSFim-L%b6;v50eqz;SUmeGw13t%ptZIE)#X#&=x*F19*_4w^UO1Yknc?l zZfp7G21+m{>qyPCDzw%%qOGHRYi%PMW?b&v`w56xEHx21s2PI$!Q@v%*tHgzmpw(M5XB;!r|~|LqkKm zUwY}KKdoN9`cudYB-NH5#CV5}6xCnO-{sPh($v(nMA^Gx81Z<#e`I9jzwf^L?v{>@ zjt@wOs>ixjY~DY+cZovvgMVlV@;JR8zZIY*#pm-a&h%s$XM(}t!QS59?d|RDJIV-MV!V$iF8Am}dl?A-Twy zsO0cl`dA4aDGbSlWts?{ko1DnOf$@gm}n(S1=YALk5yH^_y8uj*J;x~zQRbms(u3I znK4nxpvY^SW~an7=#2YQ&3?NiOcr8nLwCx{zb{Jv0CmkQlHd}|)*;eI14w(3gaRSg u;*hqdOP2~o5?n&g6}!p4MG}gbJorCMan#Eb5_Ne10000S(i diff --git a/web/public/static/images/pwa-192x192.png b/web/public/static/images/pwa-192x192.png index 8aaebcc4da731b451ddfd2403c222ee45b571a46..62e8a3b956d182ac5da5c8d8737ff3c53c76c610 100644 GIT binary patch literal 11196 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 Yi|2gk?hWM}iV46-&s?|RhD+T402#pN%m4rY diff --git a/web/public/static/images/pwa-512x512.png b/web/public/static/images/pwa-512x512.png index d9003a1959d28c7aea6d6c2fd726866c5ffe9d0f..907a991e45ca2b3c543b6e1beb81f52084748e0c 100644 GIT binary patch literal 38318 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 literal 19716 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 From 860954bdc890d16df3707774b373333a6863ae55 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 19 Jan 2026 20:33:19 -0500 Subject: [PATCH 125/548] DerDerpp --- web/public/static/images/ntfy.png | Bin 11196 -> 6633 bytes web/public/static/images/pwa-192x192.png | Bin 11196 -> 10819 bytes web/public/static/images/pwa-512x512.png | Bin 38318 -> 37475 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/web/public/static/images/ntfy.png b/web/public/static/images/ntfy.png index 62e8a3b956d182ac5da5c8d8737ff3c53c76c610..05c6fd88532ae8ccad3a94f064349141d826f1f2 100644 GIT binary patch literal 6633 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 literal 11196 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 literal 11196 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�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 O4sSW*qi4Em5yuF?Wsk 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 Uko%w%T#X=V%38NeluU#EAG3bOrT_o{ literal 38318 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 From d8232e539aceab56b0265160518fce5a118b2f9a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 19 Jan 2026 20:38:42 -0500 Subject: [PATCH 126/548] PWA description --- docs/publish.md | 2 +- server/server.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index e59e5762..81c4bd44 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1,5 +1,5 @@ # Publishing -Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md). +Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)). Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. diff --git a/server/server.go b/server/server.go index f7a92935..04133dac 100644 --- a/server/server.go +++ b/server/server.go @@ -641,7 +641,7 @@ func (s *Server) configResponse() *apiConfigResponse { // handleWebManifest serves the web app manifest for the progressive web app (PWA) func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error { response := &webManifestResponse{ - Name: "ntfy web", + Name: "ntfy", Description: "ntfy lets you send push notifications via scripts from any computer or phone", ShortName: "ntfy", Scope: "/", From 01ef1d30045a422efb2e301ff7f5560cc7b40aa2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 19 Jan 2026 21:05:25 -0500 Subject: [PATCH 127/548] Add FreeBSD to the install instructions --- docs/install.md | 16 +++++++++++++++- docs/static/css/extra.css | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index 1c23dc30..02d1672b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -177,6 +177,8 @@ Manually installing the .deb file: ``` ## Arch Linux + Community maintained + ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date. @@ -191,7 +193,9 @@ cd ntfysh-bin makepkg -si ``` -## NixOS / Nix +## NixOS / Nix + Community maintained + ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment: ``` nix-env -iA ntfy-sh @@ -199,6 +203,14 @@ nix-env -iA ntfy-sh NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy). +## FreeBSD + Community maintained + +ntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`: +``` +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.16.0/ntfy_2.16.0_darwin_all.tar.gz), @@ -221,6 +233,8 @@ ntfy --help development as well. Check out the [build instructions](develop.md) for details. ## Homebrew + Community maintained + To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS), simply run: ``` diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css index 4577ccce..ceea68c0 100644 --- a/docs/static/css/extra.css +++ b/docs/static/css/extra.css @@ -214,3 +214,30 @@ figure video { font-weight: 400; src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2'); } + +/* Community maintained badge */ +.community-badge { + display: inline-flex; + align-items: center; + gap: 0.35em; + background-color: rgba(51, 133, 116, 0.1); + border: 1px solid rgba(51, 133, 116, 0.3); + border-radius: 0.7em; + padding: 0.1em 0.7em; + font-size: 0.75rem; + color: #338574; + margin-top: 0; + margin-bottom: 0.5em; +} + +.community-badge svg { + width: 1em; + height: 1em; + fill: currentColor; +} + +body[data-md-color-scheme="slate"] .community-badge { + background-color: rgba(86, 189, 168, 0.15); + border-color: rgba(86, 189, 168, 0.4); + color: #56bda8; +} From 7a7c94cd407666fe465f296f7a578ea6645cbaa8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 20 Jan 2026 13:52:03 -0500 Subject: [PATCH 128/548] Docs docs docs --- docs/publish.md | 2 +- docs/releases.md | 67 +++++++---- docs/static/css/extra.css | 2 +- .../img/android-screenshot-certs-manage.jpg | Bin 0 -> 116882 bytes ...ndroid-screenshot-certs-warning-dialog.jpg | Bin 0 -> 117060 bytes ...oid-screenshot-connection-error-dialog.jpg | Bin 0 -> 236893 bytes ...id-screenshot-connection-error-warning.jpg | Bin 0 -> 104543 bytes .../android-screenshot-custom-headers-add.jpg | Bin 0 -> 81496 bytes .../img/android-screenshot-custom-headers.jpg | Bin 0 -> 73947 bytes .../android-screenshot-language-chinese.jpg | Bin 0 -> 114879 bytes .../android-screenshot-language-german.jpg | Bin 0 -> 150738 bytes .../android-screenshot-language-hebrew.jpg | Bin 0 -> 114744 bytes .../android-screenshot-language-selection.jpg | Bin 0 -> 88143 bytes .../img/android-screenshot-publish-dialog.jpg | Bin 0 -> 86001 bytes ...android-screenshot-publish-message-bar.jpg | Bin 0 -> 96889 bytes docs/subscribe/phone.md | 109 +++++++++++++++++- tools/shrink-png.sh | 27 +++++ 17 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 docs/static/img/android-screenshot-certs-manage.jpg create mode 100644 docs/static/img/android-screenshot-certs-warning-dialog.jpg create mode 100644 docs/static/img/android-screenshot-connection-error-dialog.jpg create mode 100644 docs/static/img/android-screenshot-connection-error-warning.jpg create mode 100644 docs/static/img/android-screenshot-custom-headers-add.jpg create mode 100644 docs/static/img/android-screenshot-custom-headers.jpg create mode 100644 docs/static/img/android-screenshot-language-chinese.jpg create mode 100644 docs/static/img/android-screenshot-language-german.jpg create mode 100644 docs/static/img/android-screenshot-language-hebrew.jpg create mode 100644 docs/static/img/android-screenshot-language-selection.jpg create mode 100644 docs/static/img/android-screenshot-publish-dialog.jpg create mode 100644 docs/static/img/android-screenshot-publish-message-bar.jpg create mode 100755 tools/shrink-png.sh diff --git a/docs/publish.md b/docs/publish.md index 81c4bd44..d2f027a6 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -641,7 +641,7 @@ You can format messages using [Markdown](https://www.markdownguide.org/basic-syn By default, messages sent to ntfy are rendered as plain text. To enable Markdown, set the `X-Markdown` header (or any of its aliases: `Markdown`, or `md`) to `true` (or `1` or `yes`), or set the `Content-Type` header to `text/markdown`. -As of today, **Markdown is only supported in the web app.** Here's an example of how to enable Markdown formatting: +Here's an example of how to enable Markdown formatting: === "Command line (curl)" ``` diff --git a/docs/releases.md b/docs/releases.md index 97302aca..254c92af 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -7,11 +7,48 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| | ntfy server | v2.16.0 | Jan 19, 2026 | -| ntfy Android app | v1.21.1 | Jan 6, 2025 | +| ntfy Android app | v1.22.1 | Jan 20, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +### ntfy Android app v1.22.1 +Released January 20, 2026 + +This release adds support for [updating and deleting notifications](publish.md#updating--deleting-notifications) (requires server v2.16.0), +as well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates), +and a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting). + +
    + + +
    + +
    + + + +
    + +**Features:** + +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) + ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), + [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) + for the initial implementation) +* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215), + [#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149), + thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing) +* Connection error dialog to help diagnose connection issues + +**Bug fixes + maintenance:** + +* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529), + thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing) +* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh)) +* Fix crash when exiting multi-delete in detail view +* Fix potential crashes with icon downloader and backuper + ## ntfy server v2.16.0 Released January 19, 2026 @@ -41,6 +78,13 @@ This is the first feature release in a long time. After all the SDK updates, fix and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style), support for passing headers to your proxy, an in-app language switcher, and more. +
    + + + + +
    + If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc). If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us. @@ -1621,23 +1665,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy Android app v1.22.x (UNRELEASED) - -**Features:** - -* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) - ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), - [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) - for the initial implementation) -* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215), - [#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149), - thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing) -* Connection error dialog to help diagnose connection issues - -**Bug fixes + maintenance:** - -* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529), - thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing) -* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh)) -* Fix crash when exiting multi-delete in detail view -* Fix potential crashes with icon downloader and backuper +_Nothing here_ \ No newline at end of file diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css index ceea68c0..fb44f4cc 100644 --- a/docs/static/css/extra.css +++ b/docs/static/css/extra.css @@ -92,7 +92,7 @@ figure video { } .screenshots img { - max-height: 230px; + max-height: 350px; max-width: 350px; margin: 3px; border-radius: 5px; diff --git a/docs/static/img/android-screenshot-certs-manage.jpg b/docs/static/img/android-screenshot-certs-manage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..613e97459661c6707a93d97c310701abb0536075 GIT binary patch literal 116882 zcmdqIcT`hP_ct08L8{U_3eu6@iy*!C-i6R1KnP6`R1lC7dWVRV0HOB|N(+JzdJRYu zLPvU2x%ocNy}#f4uJzvg+_m1d?jLtDbMiSe`|LS;&di)Wv-i21zgq=7)>PF{1>oQS z{vGbta9*kexj6#>IywLzY~Dk_18muSEW*K+#2#?|jS1ATSO{A> zCHY?%S6Ea8Q2g{?m>rwnlSK9}jE}`zDG&eWDzLo7r3Hngg`}Pe3rPz~NehVq97M%{ zf}#>4e2zd#2R=~;2_Zg7dod|Kac2=HDG8vXh@hxAfKO0NT2K%x;Qu7#e+h;|^FIoV zt@%F-bGME&uc4%5r*EjIs-dm&ucpQM_Rpob*m^)-e!hlkicifgET7`9V%zJV7Q+U6 zAjIe2=zn>{QeDjcn|6xte@XHGoJrv51OZ}6eqj@vFSc^*qxz?@DP8`Jx&FZp|HiWa z;6OhgKP-*@KiJpUPzj42v6#!{f4~m^0|xr|{zD&+rI7`B`u|hcKj9zgh@HGljIjR? zv564?0T=?*0E+*}k3Gi*??M1T@*MjFlKwl-AsYZ_i39*>7ymnt?K1%I=mP-IJoevt z|J^1&_P+N2V)sukyyuMVzbB;t0Eqt^1sIcd-dH{0NF#_+IuYbaaaNO$Z+nH z;oS8Do?x5%-o5+(aR0x=eMo?ZkMn>K_uhRXEP>Ty008ekR)hz*4+!wF1Dg(TANK*4 z;@(3t0&)sU79lEX1p_)(6Z;H88dLE5C3@K#_Pfb;nOn;5FDUj?yDZfl}1#ix0oG5+~wq}1?iwPocpMX@TBHI(>;CR{Dc zf_o~-b}P^8Cc`!t)GR1xQ18y5tEMr3Y^BSZB3G|NVi^+0?Hf?pB|_QwlSkjZN-2!0 zV5pRh+oH5cf-rX2$uh0f8spIPl`i6*wz;;(nnc2tP3=M9&6!n9*^POiNy*~5TSFy6 zHztURdH8ki?2Gu3tp*XLkM{+$t;F9BzECjji?8LJE}oQlq-(%K(^bx1Wxq``E{z>H>9qwzBLjeJtRx7F>qLO$#az>!jz7JmWjiZ%6V)V2g(=(lu8ig?C6xPNhzC?qLSD(WS$W=N67~Ng&jpM!wGWn_xCs&xiO~99UwlOn^Dh-6(sxZabL^!lopTRPj8f?f>GuSUK*l9j zpu=CK&FUSmef{g=k?h}JB-U?+FecIsA6Wf?on3|g-q&k6`uKcG1O^rZnVFf}mEd!6 z#>q$u{W)qr;9QW7GAYkVlkTX`W*2HwY$qyrR8TA^S1?BVlAQh5icINwm9mm19(23nPAye=Ei)4gD{fQ zwBJ5Is&wuPHBW3BI))Ffu{Hu+8h@%;o-nQgd@IWHioCw+Q4^8VEPjI`w75iy(ex3! zl+NbH8(6^*8@@l4z5NwDCICIaT6tv+*{Wbd?F&$>ns1_)gqI!rjk! zIS(U9J-_FTCv7044+FpQ=qq*&Kc6Dx>K&2*3D~*i3BQqwlWAJGh-9CL` z%;xcH=1u&%nLg0toBt}Dkuf!AD^eKFk&f7MY#t39M7o+zLur?tvFl7G0lXDKp}vDuTn z=kZQ}izC>Z&JEg}iXJ7%mui&`Lrji`Ncf(g2MOfadRED1BDWl_wv0j}=W!-x@dlhggRajs`9x z0X|ZnS0O!M5;lUTOr(jdjK4|;J9_Q%rxqpNy#Bi$a0jTpW(MkxSdzP+h+X0zz=-r! z|DNTC#D`e$hIV??Ip0d67WcZ_Z-e)m{ZKzpKlrIaaKW&I&ZL$TCn7ezS4tz<8U0@a zm5v0WKeMe*Wp{Q<<+21fBUPfZQw!_+NN-tf=M7L?^|?!*RFebiS(CqgkIM#|;?_l} zQM(5iev*p;riSA=HCWzAaRzvUOi!vpst=0WW%zpU&GDOl{*arN?~k`@n~i#hYf+8I zxT@jSpzel?Pfq-ae&U#;m{f|-a2KYo4BmW#odySDog_$FTPEn9E^4ZVpxsxl2 zG}7$#^46vt9!H`L2W|Hx8Kn%&PxErdA)kbQ-q`vh(bN$4`;T+uUFK~z=upp$4KCF? zKgqBOC8wxchaOjLe>+QNHXB?K1zBo#<=9TR+gtH^*dZ*mYoXdxGAYhKL@=AVR`C|; zU)0KgwO#>;QY$;d!MTj`7U(K_r;7VU}x6iDLPDUn26HiEbg6*LAjRB-Dj|WZ3o9%~T}Zq<*&T zT!^?RZomW_lC7D5j}uW7-~Zxd&ah;#(@oSGZ~C&^s_;+#H*#)jiA(z0WLb!}>Xl-hRJ(xHhyy&Ac%Xd|(paK&pAKcsSW zc#eKcJ5rqv9!-9nv=Yx>V*nlg{Os|LOvfbSiwaO~x;?^8L6xz|?*r zHK$hf9pGVw%@?Z&ufF9e0!`>Og*C-AcH_C}*xcf?f{W6;%Y8piSp$cnxdcZw=d6LK zExLFhvoC)^&R&#QOaS=iv}h0#-10?Yg`Kk+0qVF8`_=gqpKH~Opm^29#mMg}-LwspBfR?A`+|yxC7}Xp*NFoLV$)3ih=9^H5nikaYW!jFc5;CV8 zw&i8N@gI$LQI5oD(+3mRJguVK)zuNe-!{FLOboL7^1Q}d&%-Y6)lSf#K6ymryB?|B zs`x~~rGUr-q3opzXr4`xm#{J3?h(1?a^}X6XS{04F5IdpBD|2)+#!@QD^gBW*lr#HG0ugy|Ga)G8Y1_slof^2p z&!+P}I(2i3r>O;BAscdUg08QRH;zxwj^o3mPEY=sJi^Scp>;+R7oZAhc~}xa>cd|VywiPGWt}b*I}O*gi>IwKbaVoJ z$)kxdDdUrRy@KV1%vxH8bc3M{P;fK2dI2c}+n8kh!@1=lrDDY&lK9PLYH*(Its63I zvd?yhjQ}MjMZde7PCvq>QxA!immv#pL5T^%E~ehn4~uvNU*+oths|g`dc`r|zx!OU zDvvsvUV}#M9Q=AZ%Aa3uu2ZP7aH4p=IK(47ZV9G6XQP9<^hf1jJv!b+LFPH}oh?R29sNY^~@e=Op206*{ z&-Weh%y1h6Bb3S`qTu}G4EWqE1E%jt7h)n$^nan5p(1VHp8TM4je)0UlSgx!iRJEf z->|=osWgle*NwV>L)TN3Fpk_RP^xkrP48p&QT@Ou2yR z&71rwy@U=}VHTJVfX*6f9kvK^ryY(*6Atyp4EyYlg%5n)6e(KkRpCH zt?cnIs~Z_RFN2Fzk48$0#)AzhwB++He-tFog_ZKS1d`CQwX`9H1pD>;cA-OwB>v$% zM}kk0Z^Q8DPgIQV0BVd~%T`SI{pp9|QGI>-1AbNn+Fc2YSF+qlV^T5vM$^Ts5u)8G z(s#45#%@VHBpIm{u?I;fZ@$esxwLZYC*LxkaxoZ*v{raZEX-M_mk(;g*-pe|-W*lm za;gS=0NMnMeL+I1MW28AlA&DO-_Mji>-o3l;f43hNa3bfCNZBChonC_xyp;rE!vCX zgrwN0GuI{bZGY~XY7fTWz8&A_g1%j0x&!ck`71j1{mHG;XlVt*?UGCFny~9>L&Wn~ zd1JF0;zZDJpzhDf{blZ4?w9NDO=S;Z4(tyye1T2ZY0fzZ@vpxgZ#&qSTob+APaz)t zdPqH&aCm+hDt-q*-bxfPQ01CtNRSy%4XpXh&~l5?Ojcs>r=@n=x_~b;-X|A-6%?(i z*-BWz4!Pa>k+DQMNbFzxljB4wtTI{_T9L-4a^%vt0AyY(+0fp~UTAr@0lqIOpjwD|H8e-|%pc&fNiY76Y9!`P3~H zcY;-4aHsKEPw%)6i_QgV$bF2z5H-m%vh~+jXuCLj`iJrN1|C&4_;XN3-_9+^Y=!Im zz#YJ&zXI=(+oWoGp=DEB4!4Dh`0}A%ng?^p9OR5KS$gh8gc_+}5ZT9Ko^^CoPxoiZA61QY zI9ag^ahqw`x&>aEDNlF&&#rcG3|K!m7&*C=^iDc#_7|&akgC7}RyGLjCtCQCY!{*{ zZ(}Ez`(1Rz(ReO4kmPtCO*kR^VJ4@3jJwgOCW5|~M7>V9P#hIqqMxoC$;G0;u-6=& z1h%Bty*WVScr=T=cTW6MN|U?t!h$>f+rFf%113gnw&^_Ef|GmL-@3oz8W>U>=tn>) zJ2=A&%8(F4d_OE((dV0Tv8HRrP#_{bYTG|O;;CPJP5=WcYdewO23@H@o#ZOw*j^{k zP^CGuMO=S*1%4q;GSfWWL|!1bTt6XrShra(+7yWhKtD!UL`51hj$cGY101u+eMiR# zM7m!x^sm>b)>8dy-B%r2Ocy%paR5K}$6M2?FfPz7;rYaLP^5Cs6Fs-3~Bs zEs~)GB;+2B(krxXdqbFBT*lQ)U8Gxpv-lHkwIRI?h{0ug7|~(tu+igRV1ZTO!IXU@ znpAGHuMj%vZkdxoAxb;O9*Ajsh^o7{N7lu6>&Z#QMuwJ}Hipk^$dUa7&rYUdC!v(D z-#;wlrkBVtc`N5SpvUWwX!9YE2Z&qO-yA_N9M?N_Vjq3fy}-MyD)(YL3T5B&sQw3Q zHH58Hfj#LCP*2aK-@wxw!>vxl7iQARZl1q3>BNkt&wmgy<>TD?d^9xg*1#3-fjFKc zU46d-B%bu1P7NEf!T<1>iDR)929H`ik`xCq@5+c>;YX?)rH(p8l$iE2Jf&!ymz(H z5$r@a;Bu40;0sLXsXKpu`P2kg+xOyK3lfAKl7kQ?mve%Mp{UkCj&z?0~4XO_lDOIow2 zrkXzeP-#+EVhEA)(`3eFAdQ(h*aE>W#uF5!m9?ewkaCB)gc$0gC?IcF9 zx3Onl7t9mD8TyxTnRsroKahBX?%IVJeC_DR*dG@z8R5VeN>f5wp>tNDU*L}v)k@uA z>Uk#Vei;R$;$p|pM!U9w>^p$?z#fKvUDBVbIw8z5l9x(NJE=3D=9y|)vNR_!ad2&< zY~I3ZvUJ8HW5|fz1LUQc)}+fzQ?a5nTQ0kNAvjtfP*)PxPICu1JG%q?QIkXpVHMA6 zc|LGF@U|}gm1|o^7S6{Mttt_6YJjXebjHogNq{Esn0EPR3Dre^|j01*s}e9g+GUQAV zCk`ildo+|42X}s%)`E-qZS>yFu+Va;;>jku3jxb4Tz<~5P)@-9NrFEl)#LeRhdE#q zD!I=@Dj*gf`Zfskf}tp4_`vjtL9}>(FZ5#UmVbOJJ}svk0NF(vvSkZvL^CK(pyf}X+p8CE} znEyujH33`*u1UftY&_|DlZuj%d|tfsF+>rJ%x!@+JuiGX1tCT+q9QX(7LZu|!$m^3 zS;cbTHem4!;SRk4s7xkj?XkLwi2y9Q9Fne!SE~i_&v7 zSeW9V1bID~5P{vfXhXfNe4;0n{jADq#cub$rCiXcAZxk ziPq7onw5-OX}>pwX$HkYL#%@ydsZPb%m6vRW)#S*1TlkJcmh}gvjh~<=|8B`i%FrW^Qd<={%mP*#r0ulL_De!vxAY77hu82 ze|r1n8IzJDBdQ_pv&v>qKe<9vQ;h9yzV2~a?KxMA%j(iDnm^$?!sM{h+Cu4p*?ryC z-DT~ZE|cJDpYq*N0ls&$E!^Lq#!j$&>NRWqr0D!p#5HAeHw{CpolU60YndKve9$aX zjrgW*QIg+0k7`dZxkfV!CBOdYZTUi=EYJ{j2Y^}r$`Vb?jZduA@=I>op$|>UWQ3-9 z$Y>#)?J44_b#%d!nz@iilItXFowRONJF$2;?P-xpY{?4{&`9H0lvQAjQEj`{$9WwO z*ELtO?~Sz`>h$o{^DJv|3F1Y=Uip%X4O+I_2B6_NN82J@RRD*62c)wF z{Z;KD+gUW!;R^J6l_)&&S8RE0`85bywf(F2N>|r>)oYPDzG_sm_(~qsbU`B7>5=a( zy&*NGIc9IkO)I2?>|hwROoL7Vm$Z2A9@~F3qMdVy04hg0{-Cc^aJo zr3lM$+7!R>?9ge*a=-VJ?sNzy<*GK;dyKFpVXCuC_}0G<)=VylExV~Hf4P@~nO-;t z09TW~`jCu#RdT1#&J!^#v9bqW@8igqyvVe9!V^ygY&|n#*n1;7u-%kV<-{!uk?CZ2 zNB@=G>ebS$QKF%VFA-p;*G2Dr*#*f^txy(xDv+&`<)3>8dI&wG6KbREAa_3Ms5GRo zbh7>atfN`GSNu}e6D|}|WN7c4=JqqitDWAt-8mfaY67=)=zu=@JSxG`2|wHR5`W6t zmLN;o@0Yn5F+%`_rB-=_zUOmiUOMIOM?b3fjk1lQ^vH}K&SN>xcelHr9b~zs2KD$Z z)TpPjd_-yo>J;r~e^^ku*TtO8AdV)qW)RF~O&u}S2qD){KBz;xL_l)m9;ELG0%^08$fF5wcM282El&VooZ5o;@E3oi5!4Zmr+`W>8IeLs5VEz@( zx}N9IA3jhgeV49)_qtFJEHPG;tG)yYSA_wHmKM8o<+{f`cEc5LXZoc+2)e$0Di|ij%cL1@g1(Znq>&XBF6`_yKdeXU$cmE~>|#n^A>PseBw^b52mml>XT` zz%(3_vVoffSckKt$D!2hMoNaYu0@sn_q7Jie!HuwIg)xfi!&UziYrN2G`JhbeCw@o zGc7a7MeLGqciDF2H@6AUAM5x%vk}s>6T*AgP>_*6`EBK)p2O~Lq|6(|FPE+2%HxqW zYXK0cMTH}i42SiM$9XWp2pOvGA(4ZDuT1q4T%G}rk}(o23)O)`({v1l&$~tbdb@>y z7g-yu;%N08N(~wjIBLX#VP9wUOXyN!oM~?SR&=UD2*l`BbSJ;9jr~-HgR>Qmw=6V+ z(0fKdCSpiqFB9eL5f)m6Zm+osKa`Jp7}`s_R)$kj)%9;U1fbcyoO8!`zO+BC-yUUc zE^KfcNPN)A*>#^F6sm>ZsMKrVy(gCr4PlR+u4}U2bvTexOmz0$*di~GQIM&1H*gb2 z-J0nWZi(Fig7jjTsh0wwdomIr5L3#!kJx~q4QzT@7iQ^IMj|1EeOI?thjLDq+$fKk zUZl*A>C4=5$@^oG^gUP>N z;$>E{wPsSV_ooDtYF+=C;J#=zZDIZEGjS=Y>{J^i zJ2t3}@9Xs_xZVe43u>Nm_z|+P@K=G?M*>tJDhox(E7hBBaivI!UoHP&63S^-p&3VX zf6v&7gFc;3Gs|BMPqe#AO&lhEPt`pUVvhWtw>qw#-06fg^>$u>_u0eu2Oj|!c=kAk ztTTaGYs4;~=J{(-opQUzG6C1Yvxx)JDgTgdV8r{63n{!7)!)wXECxv^%uV#ZCEHwh zmu98+m1Z1!s|u)j$S0LDWECkMm-fk3x!7GK(4dAEqytmaH`B8@j%QVyWP-W7c1S&n zTi5B7RNDP%o*7a=e9{+(Jv31#r@rp{6NQ^jl4Hi%g*xdv6duv|D%%o#TBj_Qb;3EZ z(h8%uMkSI~Qw+37q21V?cp&YZwS6HG)PIil7;DJJ3!1Y5Z_saGORI&jg-AhJ>h=38 zdFKUCHX&%q3CT&OY*dddJ;D9A?%yv#b$5V-x?CcMhN3INP^SI^n<71;ICQRy8>k8z zRi%tC+(eRF=X14WBCE7$$y^8d6rqW<)bDeead9MM90i)J z#7&ygZ}<4|R_}#9!JA@kh5j60_DznlR`$_*>sm-L0s4*3^-s3GdxsHmMj9~FK?~}q zZ<0qg`L@4Vmq<{(Qc5+hW|8g+P-`EkF1Mu9->tDOy~=z+EMn8h3xY_?nCG28B8PZ8 zu+0&bp5Gf->Uhg&D^y=KgY71whKg(XbRv?*s}@AQpAYKdx56}n|006E4F#cX+SH;j zFk0tpP-FYz)Q3};XxQ6jN%jvOJS@%jPrXrZf4mVwAMlx_KLARvX}^x}JW32UYx*|( zR{!C%YBs}h%iCUSInU;ymNiUuRBFTWTx~uZE z3l^4TmqurbJk58ZQzIjt(1}Nlskl{Ju1jMy(em5Be@8a8^>6$<7_8RM^&6Gmj>9dU zuhM!JDt4K>*ZkvB#>LvZQD$i}4o#m0WD@!{@W*o1 zwCEs}zYf&apc91hu2xwWK6g~#&kC!K6foYS_wdo9tT9LX{FuQ_C~z5;j~63UdC(@$ z>*FKUxWAV=vseA8<{8nbD$RSAS{zrFe$=H($&KaSz*_}|cXxoiuqAY>PNveUgFJp@ zzlk#iT|xjywa6no3dL&jbn2!<4}`%U)*eH^4=Whz5Ztp9h_eR z&U@1k|Ces?`?KS=NUDt=IR>_Kzq`*ShcCW7nz_vNIC%5dPC#DBlNm8XqCuH?3Li62 zLhoLE(KS2}HyYtiXOR|FvqRldIm|#oT;H`L`AeIIl6ITATGPtXC7bdp!;KC*k7PnC zidi13&W{L8%|*}&m_HLC@_No-`U5i*86%yqbw0Ex%V`Y%oZc>6fQ0WgFJ^RE`Wo{J z@dCAi#s3glsCbTe%=hk3IarQQ57RBHBq-8FOMVb1G;AyLexNOKBEg>z32Wu zt+KWg10W6{qQ0BH8Q(?U158>hcur?UWmtibAjz&|KZ029D*bM_a-l_GCs27=mnWY@ z5eLc}E{DjhVPX2R@EGqoQ`ey(103h=cNrfYsjhn;nO z(YbMJ@4wc}+A8On%Wmie8WMGncd|;4Si^$Epr0RJ6W0D9U71dPrnE==!& zIM%@CMe$Wl+l;28@DDvgjmLO!PhLCQ+Nzll5$@?nBdheg1gD8obEp@UZ%%9SLd*v- zus7F8`7rR+#wnwhy%W}qWAEgF8EkaRp{a&k13?#a)|kPu!F)Nnum=kPdn%7u&bBu_ z+F^z3#;4Fs?_p|r(b@v_ssY{vOAP#rn!d(`y3z1<0vpJgJFbyOgn&M`*-JT{T$^ni zCf+|wmZ%%Nl@mV082M!TZ*=XekC;oohF8-kA(~>;-w~~RY@{5}0_Gj7OU=)4#3fra zCBs@58EJx()brPzt1gZACWxh$E`=)D9zGXb$5`Vmi?Ff>1pbw)(4vb6+0;Y_41e{k zN^Sx(m=T2|*V~6fymEP730}z((Jq<1QE_fV#fC?4Pr(i0E{4OjM{@Jn#R({W0~9a_ z3z(BcbpU^16G|NUmKbYydP|(3g1lsUK~UQ!2S3=Jp*IsZ_nO-D*TI;ysO1$Jz*9)h z{cf3oOi*c7Z>iSEQZcvg(u|}$Nr^6U%YzI_QLokNqV?lB?hiRqDzYyu`R}cG_O@leoEvYP@=yvcJW#UCM zzJ+M=4t9wacGX<_y5&$oyTjzoWDaVYPOsl|Ca!E=|1%D*TBI0?#FR$q)Zc-O-zQ&(>5^Qjb zmrF(|&a+$4KvlT=w}#2%YT%9p$~xmDRz2MVlY+Nb=kU2__625l(mU{`};O;8UtW&I5--(9S{Z}2~omyuJdU` z*Lt0VDAu7R*iFU7``QQFsbPOx|Ig8#_@n}}CI1@xR_EyW*CCKbk$#0CS3-ShK3lr}qr77* zBZIyaV?kS8UfO8xWj0G(*3RNF=cYT-uc9vYDTi^6)w;tR(~1zMW~F zz)S7HRbFT0br^Rnu&5d^+U{PQ@y9a2ZJ~nBPv_uC$C~@^S4(Tu;OZ9@z;m+gVHp@- z+)562wNg=PMXF#6nOyxR;C5`F`ZZUqc==0XoPxHp;$-AEw8ggg_ybz0@M}+mUXT%&Hax-aA0tX|%S$ zoSmDr^=j5TonvOVy=IO)cuV1GtfgAw<(;B}S&T zXUn(wD|gO);b-}>8K{*`*2fKeV+}D@s2DxncXPti&kPmG!zP?x8j8j(wwSIS9c8q$ zy4P=M&o>d%QGEj`MwQPUDCJc;Tuip7?oA~v_Cgo17GFm8L$Y= zxp+a%>kiGHz61RF)6!BqoiK)6M{Ja@dK|R-9Wz+Q9rMF2uGH-5tj9|hMTJDUC;HlR z%l#8VbBeaC zDkI`F+Lte6|1^$Q_CEhzmxsX`HWH;!5bWYit_}6i7`?*!=@F%3l*e;RQ~sD%yRjRX ziVbTxzDns&Ra=9`ELN!Kf`lo?)T~@PoA%aqZs?OD2)Ww=qBcN6Fw_dOpfsswHFrC2 zQf1g}+n%^gq2i}LJtKXSyf4Ot0kh9& zA{KzouWDs@fg5#MJoD}Ij7%aqU2mO%3!0HEhH%X@*Ca;9V*`3xi&m7TXc4&j4Jajk ze90sKyJu$eJo0S-BtCH#0tLgGQDSq}uhF0e?6URACp0L}qgp-;5>J#4+&Q${U%XhX zxyh);bc1RaSaiuX`41|J2tRK+n>6hxOoFBtv*_6Pi_BT|4X@RJ$$=7ERcn@Nm{$fm7Q4ue%s=-dZl^}qeY%X z4XooyomET@=m!ui@V?sJf5!vVm2QCP&6gRWmVoZ~ljZcoNSLpyy)#gT+TM9D{t7if zSCF$IpAj$xs*C^KojsPn#1^SBLj7RWz^+BvWRfj~8>J1^n`%t-@*=REEeh1#M+kxY z5%gw7=B0|+>9>Cv&6;Sc7dUN5L}U*3t(HEK2ikxG^~a7r$tIriEu-rCr(#cfJ{q6U zN3t!%H}dl*cxusqO6FMJFa9aY>+K`JYdu-AFasN6hH9y=5JLE=cT+QemAbY&M_@#= zF^lCLFAZ@`*7_yTS9(mT9K9V?@{8!uQ_BFlU*JKihQQ7j17mMpo>0WQ4b3c>uOpRm z4bqnR-R_GZmL9)~#{ViF;frgx6JYi-;1$~ZIseS!43Fn>X{~to{6dr4s!*%84zS@@ zhgy|0Cy6m(t8^Bl>chEE3HnN;fro`^>G^x_JZ1^JC{&!)5;d6Q~Yv zX!Cf3rby_U+hA9(+@sROnwF9d7tot9xmlr&W9sAnvn7hEs=sNwRyox!i)CL*VF z)q?$c%7-)hmCr^W=9;CYwLjo4%O`K-vtu-Wv#cfUIOwrocq<=BuH~TDF!Zq=-sPd0 zuG8mTla`#6_l_P`5?_xK6&~U&Kuy-IEnrgLj(bq5bl>_Q*xdWN)0UTLV)YNKkypZ% zSdMLtE*5EAptHYV?y1T~PYJW0F5?j|DmMbrJ0ng9uK${2*K2>VbHr<`-!68>_yoz% zkSDtupM;(sLb+C4{(AelIfuwc?}adc_OHjjGrxKL`@xLQ6AlH6rR96*p z1>>MBT94H**my0|szZJ+G5m{-XeP?J(*X59gnkpoTIvws! z!u=a|jh0B7s7?N-G0^}84?VZ9qC+2l;HD2pD|}pNwf34qOX?(Bxf(C}`G|teJ&kH% z0pDwkh-@>)7tOCRmBEDcaR)2y7$EKGvIi2dEwSKp03(Wl7ZeiodT@R3Bfcn13Mv-i}t zOtc2Y?Yb$Oo?as9_WA*BA~Hp)?YV7+>_GVdKYe{&sWBKX+PD@x;NkNlT9JIsFg2ma z_QzlFH^c8nGNh)hZw-=J`dzU~(1e4^$7u@x^Q?qaD0~9xX;T+*@!M-uG;5rG znuY`EE&B^n)P%2PXetR+*Dq8y1Jd56vkx!oSH(^T!R zW#TO@B@&L_`-!9@HC4NR$GzXULTqPG-oJ&N9)E)+ap|r@y;F(D21*PK6FKPeLvC(g z|1J*FwF$qwFMYBr1K`To8<&>~&3Hy)N+ z2OX6A>|atm-*Pz6@YU<>IIFC)**IyQte-=|S4oxw$_uE3yjyHUbyzPP8Zc8@_qj0% zd0_U1YS#H9acK$z&(R;HBs1MlwMourg(A&Bt2i3p#El+2nET&G((`nBg62hO)50+sE1cd z;(qdbdhx{?l(~tJ5;DbrSu87E6`Tf>8RJ&9(5Me71{~SajMsuG>P|K81nkn$*;*Tw z$xCNDjYDqVUQ8S~oMRs6bv2KN>lm~t?=&w`mE>R~QNB(`fiS|qAZTlqh{sCg)sr?n zy-llu?)wWDa%125QEe&>-W_*L*D-m&1z|Fe^%@)-%ouQPaRMHb_L)%m$j|I4Aw;Fq zU+rSIyjK~5y2~EUl%C_QQW#5mUD+PDKj2T{(_S_V+~o4EA!2-Jm3LI$mF}eIf18Vl z0=0Qe!I~F(Za|HA=fT&32e+8bI{*pJ&BoyO>n7;c*x0X|tFbA0>o*@B`hC*1MI~|v z=W5$hDr;zd;wV@&dkT@`fsr8+l$9MfQJN%)a7V%b*A@OAyU=&R;uO+!8K-+UfF9G=ln@V@OL#>zuw#(yb-jg7;k+`L(Bd8 zItIBCrbA+na;c+g9^p#T@9H5CIy0$VQzNokdCf_)IcMlU(-b38zUe3;u~+DKvqog^W(YL?=@I^h+*WMVye6bwh+O(UeQOoh zZzcuj6i6cAP#4XYLz5YbkkGC_YgQp4x8}5gIJ%63j(g89 zG9{WKA1k9y3on4BXt`!i?>yB&HQ7uoc27}`KW^D zl~6TDlO@O%@nJR39N+#pU?Mpx~vX!8mb1F60Ljw&VV+&2JJ}}E7$8>A zjs#Xua+cDb1#)CI=kE4@t8+Vu&tZTz6wJG;XQHuA-e?A_VFlD%Q{he%q#f$bck>y6 z;VAw1U88x~%y;;w|HhtmXYm6v-T;seL#Ts8u;O|RKDV-!SLv(9YqoezDvx;geDrT#u`$3f-~oUbi%r&**p%Q9E|%kO|m7qvRiUXm{jf+4_1 z3G?$54Y_ti9~Vcl{)D3WI_Ky(;MeL!e>VrYQ%35k!{8jJvA>ylW|pJ1v)U)@K3@jdV*F zyQz0MAsp7TZ5phMMb_Vz?{S4&=s44Re6lV=K?#>fW#s7^co7=JqCZs|3Vp6*n>sjb zaz>8g4pWWvAcIV&(=(wBgaz7fQbrEh2e{c{{=~6P^pO(M3s>}fpLDG{QT^FAhBl0! zA>}f7>I1oYpo>4}NjTfc(-gbJ%S#Wj<+Ce+*BB9|cOhHsO0rP!wE!ruVM+_TN8YaK zaIB24Kg!2+5y&bWm#>o$VBK@hL`BC3vEAt)U{3)&Gh`(hvS}XE{>AVN1bUGkiAa>; zB&)p-1QsLPD*m_g=HF zs%iR^mDXiVr8(|d<1OzGGKmqDSuzWq1 z=kr?I+0=<8Gn?4U^v9xx^ve{-nh|l-{KcOtJ=ZmNkeUSX5?7xwnn3|=^dy%geI4$p zl&ur-!e_TPQk>8jk^|x=$Jh##t*}`$F$BYRyX`-8*`8f0Dz7~n6-fV4wn8QRqDf6( zHIKenXWmq^1XinLc)wXcApc>Evo-w^N_q7M*lJK?lzMb$$_d`Y(fsv4qD1A_U z#LxeT6s0L)y1u*LW|W&7-Vo-@W4qqENkokhLB41rOccc$+kl``tetji*aYCgcyX@d z=7|@OH~)@N<0(LX7l)1L{nI~q(>vl{2iCzI5d1oDTCiGUU7C2L=6ofDXMZ#s>dcdY z-3?kuWC&V2JN zTZ9aLK)AZX}1^>V~ZPslItkxzib%#Y3dMQJc9( zmbF^Q?TVyIk19|H4`6n!d#lPbTIZ+1>R40u1-Fd+dD&fEpFhb4egytaStp z#?xshDAwJ+)*QSX@Qq;92>PLkSR2xg3-sH{2oAK#Pc#NLquj>Wic(vhoJ~qa3**_= zFS$DRGW7WX%;opBlp+Or7+$iCsMCM_!uNL&2MJXR%h_*L+zU{?4E6hs=t2zK51d2D!|H z+viPbOA7_s;!caZThO++7k76r1lP8d;sp1Q77y+&ErsC0-3kGMQ{3sCd*+#W=l<}{ zGi&CVS@Ypu999z6CY*52-uv4B>wo<=(fWQvcXH=mSfr-`oYJkM|F}q6SiRw9JvRFM z;=FE0O<&0;p1-ZiADE3Y+@YLh$y zx@-z>MlRA<7Oo(_6HPeWAT8Ys8+_I;^`;(`2FAROZ_A5qFHudbfhrGT+7t+g7ON*d z?SIllr|B;F8|KxKv(;%cnc+7irjwzqpWa9IbVe)5^ojqjbfK@Y$egO{lNX!L%MmWf z7}I+3DK;0!R?`9wyWi4(F>ry}xC4gL z%!T=0}sQ{fc~r^b|Dl978UEb&gqsp)Fh*s*yrr`G&*F@B!fmkBrY8E}lO< zNASY)sy}$0dl_DGaLb)n$~Dw8SyC!haAVgC#c@;ZT#B}9A!s_Nr-~)hjMPd^bU!^O zXCte2T$u3gmNheKB-PJg+lcC@i!3!BMX^{pEbB;QqTTR#E>BHiIyU;<%(%de3ur4s3s;4!&a4cWFG>(x^ z=8|7lxu)-1Pvpe!FY|}Ecl>#7Lh`Tu)2Dcn4dV5|IO7DKrYy1VOsLK3IV=3rL>r0n z*F1$?rpey%ozD*A%o*nATMr9MsS65KtTxQF_L)u3r)n0>dEglqxIIy~$pZCfdj7ml zop@zX+EFAfkXBM)m^{8(^L5-yGfy*d8@`DOP9zr3njW$4cj}MH#in}=f@24UcY=DU zJr#llfA&iX7AR@OD7XqtEawYb5t1hkE_gJ|w)2+}Q73-mvTon=#k1xu2>VKB9h!)@ zNm}ir?rnElB+tw7!zHSTGrGomxu-ijc%mR?-gi-Q-c~Y2MZZT3f54@C!d2qXg;~ps zB-1%}h6HzWPl97z^ZMsSMN|LvyH1!W$yB5exlU`Jm)zpkPY1;8vX@+!pJV?A<vd`}DPW?j1CYVT*m!hGLqWt7Sh!Mqaay{-FDF zcd8gK*;`+Pz-whkU}P$1HnyU{HY!Q%Fhq;m0p?UZOk)$G$Y9Oe(-Y~B7^%kkpDt9( zL{df`PLvVjrG1*yfhbj-3Nbp{&)HeIZ0`Mv1$Y?{gl3n^*P76|84b7wYJtZEe`iND zyJ zFISBU{)(b+9>vmSgXfxmR<3?l#@a4H6indUzRIV6#lTp~$ST?t-DRC;uuH6M@ zh!;$BFy+%|X=rN72?s|ac@xzILE$NlyCk+5NN|c0zTeHzpgkkBOS&BVIMk;xufwXgYwlu}XhHB=I_VL0_|MPaIcARA7mS zeY0%esIvuFbBa+mOHMDN!Doo@1NHiB!sctha~FO9Oj`56ldtF?poWEp_X>kX>2@@O z;Rk`@r*F_-@r(Zf7E$1x?a(oHNM2YDFKmPiP$W8Oc`#&)xrL@VP^^5LVC7^ER`ilgmGB z@HhX%U!)n6K7!v@pQ+a_XWwgXKiT)~M4^v<>u7$gul%RKK>OXPqx0KT=Vi^W`!5o? z;V7*;s~wnKJLtAx{Q3M*Nbd;hM@hj0C$s;)oz%OTppdtvAxAdhcTWTU?GzuPQVI` zRtkbylKi;*pIhENGda7%D*ubolS+FED0os7uw7d18;R|a4X*g$ndp1T5Sqt2EYA%8 zx#2ZQ>+Ut45s(4qklK=-X9*URnwEs02TZ^QOkUkplHLa1i{q5MC#nDEhNql9Gp7`d ze=$h(Lq@~v-rqWglm%(+T@*BGZc-(;#Hm33P=0yhHcN&EU`|45wtmFfk$8LqgSef3Gp78}rRZE6c9(Ju>vY&%e z3hYfYR@CjtAE%LGB~GJ1kT`$sUuF8$Rz8BJ?Yyb|i}3?UVZyFXX~2H4%tB#OJs+3X ztoy+i%Fj2qZS;H0N^G{9MTO54HpjyC(p=UDCtvGB$KJ9f5a&K5pmUx~3@W2Aj6FZE z7PZL9d!l#=IYsQk0YH}q2+(pRRD3yq&E2aF-U%_Xnt2!DS7!^8$-+Cl|WTC;(^N$ zhED#+vf*}RK$8q2-g%JOV0WQ1;l)mjG_^7d5JXPPP4#BC-2BA=l4N(ymGgcG{J$9c z;y2kL-M8UBPCmO|JeJ&Zw+=qkipmW}=nRpZ<&3>Z__5meN`v(+1>9VW@TPb2<6KqN4oCF8AhTk@RF6}(!m^aI0k`47)ic;$!FHyF z0O-L+o$8BkOhU1ICM4U;Dw5pi7!M&eB3$2piTju%h0-9=nHDSmr-DNjRF=sLfw9>OVXLdmYZ9bs(6mPf6HwzGb|!F2s{F6%q|FWip?Jj+v7&kuMZGhE`YOwYUz+;m z_IwgghXZz`jX5p0poe{rRx+3uYbo6_7pB&4&&hGtM;j-LXepC^t>iviB)3(qL??YM zyfaeeR6E3p|Nhg+!V%mMALLZ#P|;uljr=zMHK_!vJ=gf@2d4n6e9%$6*emWnW9oR@ zm_}Uz%xX4>rpjQMqc^217%0L|=tx+47aUksro%Hs0@}@?JP&Y&bTrDDQc>?-QH3Ru znRZRQq7Rz6>59B4SYN_LGHPE#2M>$LOK|ieTQL2s+8HIVT}{dz&m0_iN1iqQ%>5HNE1uAx1Y#C z%sCb`P!(p?fv?kSA{C^cDpBwA$4782xswBj&HztOO7uBx^V%3I^ZSD(9llOxN6*w< zP7A~2fuZ3^mJCYv4-h9NUHDH=H!rrKcV*w6q~4*tfC6n(r_cr{S3$jgZ)=$?_2lgD z^QLTBEyauNsL) z$$|0vZ1N7SnDIgG8L1X`-HsA+bCWVHOJM5@zLPz<&6f_M^Ij>5>dO87bn56xaS^Ui zrKzetqf8;vV?Os5G6O6Mjb7%9K5@xHnP{IEE<9~tQ7qmMcLXcl2MiYvoy?{w-ka)= zJ}8;da>^m2`7QlcNY@gx&`uIQ|1(jkY~wa%bZ)~}@KbpJ`_{~(G5c|us5(D7@j~q> z9fc*zvhawPTmy;>&2L>IBlI;g$&zDZR%EVzn%OLoOAigfTDhre zpBhV1-;3$r7ZrM5SEke=GV|S7O!wqPth{}+Sm?y%GKJQY>iZW`Dxy?|oM|=4jy(Kr zJJA$hT_(M$m(*U4x~JcQ?kOKTxfG?4|0you9uIghRbEdqbwH6lA0??4-fZKc!#Zt2 zbDaY36BkzJ>@{7h-*0lAIb*2%8c+T{jbQJi)`Kro`{(L<*-!L;0zFd<{6x0K^UPEy zYU&wG@7b7w+4q`0>eL^e0JI}bj%X`oyFkMzz#gjM&>TiHe~bLCrB%WoEeM*2=6+dM zNDwfUrkRg*nw8G{O*JkYTG&YSvq^~VJ;nov|9t`B3EJ6j#@T8Y3Wi)iAH`&=Lpc@> zhFvhjvk#0OC-%Nh4ctjIGf|?B59Ak^A|&G6Cmbl=DWmMrdof%6mQ~m7VBkWYys$&) z1WoNjFg~qfxOpaNCh$e-SEegXCODvyb|tHW7`KC`hNPf4gYe~iy6xaRwsw)n-8qkG zPs7~Gf&vK;aKeuDu6hn0Vh>}k zK{wjkx11%JJNP82;)2V?`JDTP7S|75>l+7hw_h~WNs`w@{7Nce72|sEc7Pmh?_lL~ zT1B>?H=tffDGn6CU`2*>iXcMD#I25dCweDn)d#OTKQuSvxuFQrG{@Mh`s(0wRK=t@<#9VgA`4o~eAs_vm~iHoS| zFmxrJlyrKfIh!!}n<##YzRDbW|C+iXkv=ix#a|= zF{`}<85#CE3)9smd@?Xtu-!~2pOQ#Ij67;G-VthfGwm-=on@`zsp-|11&w@PZCXeL zt(s_mdf6Leg%G#eh71B9V*~Z)wiDyr{mN?Nr1xM14H>+V{75C=nOf~?XyC9Qn)L~* zTweLZnj@Ara*%9qvuX zy@q6Uu4l{K`hw1VPN+f#0#^JZ?C2=w$LX>!JwlAv;rzO4yh>rnr<$>MFHHhHh2bm0 zr;hiUZQDv7w8%ydjF~~3jM#z&19teOX)q*aZ{*NcZk%1WoZs0DKP$08_D`IeEE6M1 zibtK*8aQs&bR zAftVkw_Tl_IWzg~cHdJdy^qTIPu~y*e-^Ko(Z!vS3sbZ2NJWYN>N)x^hRxjM4gYls zLqNz%c#|NEh>NHF`o;1Nyq$1x-Fp1E$kv&Q)##HF_CVn8x`Bon-Nii#jU8qrb*PEk z$R_v;m-6$dx|4G*2_^Fu4)QN1`yIyH7iXX9=eBd`pBgr&uoKcmCp*_#Eqbh#&dWsH z?t}dh{AazeU0f?NK-xTkfsQWGC->FN%Gi`c+x)#9yLo?H0!l+P)>2>5w8Zy6XG+ro z4J!ZWqclHk8}%HTK&xl?olDTy`IU1NeY*L|X`z!iyVv3*+A7NC*b!T5^TJwk+`%O1 z;_GmV@SI_jWS&w&iDW;m>&zZA;h^~KEBVCd*~`f(60uquK3aHLkK%vOT2s4^f=L?Y z_=C%LjZp-`spaYUicvocZ_XRF^X;tJg33Mo9@&vi7(2y0lLZX4G`ums*S-iz)8LMW zWC(E-@O_004D9f`MsHl+A1OyBG+n?V*#&q6wwfXMX*}YFP*IDYdxgl+LPS4iIw*D> zI{GAoC02N>PlC18Et={oXGhkxH-|%yr6oq5GNs2Qky%eJk)+G<;tK?8pHVxqfVFf< zuuHo?DOZrZhh6;Fuu7(vsza%XkEOrr(AbPe0k3FWZUgx~`Ag2GxkAjHedYR^`o{jr zo4ueMc)(A&h646k;bo2q%l0zgldRBluWLJFJMvrp7~d=O2xRBv{yI;gJ4X@oIazxL;}#LyPVk@s6I8bye!umeK{zSE*!7Z$2Ee`oE_(u>+61`@xy> zeor~8E}NN84=AwRWA<9SSM^M^O>x38Oyx^Oyv6n7<{gf-xA`Qjw=+Xzv12;TCR^&( z^w!=Zz2K*Tf4GO<+XQJN{_F9#y~Ej8bRM60UwfGts^&1pAdT3oDN|b-X@1h$E7*p& zHSZsGP$j0V@+uR5O8bSa5=K&e=pv^6U7dh{0oKZ`z zl7=(&toHo}cNb75G0k~e{Lg97)#6P1ILh*A*v5t#P21>sh}y6HIF-GF2-})+_PD7Gq^{iWu;lSJ1A}_yheJNQkMqow3l#}G9fR7Qtw|On@z!bp5PNM!Y@4v zrZUv|F{50rYC7t&$5Z++v7DSJtE74U7f2WBMjVi|pwhrpVqwO7S$jX6Y~Q|Ac3~E5 zv9GAK5Xjcqwp$V0gb2K?MU!2jj-QjA#9a1f2TA#y1)I$>S-EOB(0c|-T+iw54RAhp zGO?rS6DV%_1S{hNNApWGe~jtn`5M3Xi|?apma2rv2vQ1rGRh9CzoBY#fIWG-G)a26 zWloi%TF9KUp|qB3GuKFnC5D?fc!8$CffY{%>i!qwg>Da_R478+_Xt9pwtDyDb?2q2 z7n@}YlO{g+7X@X;rt#J?sx27*6r+%^Q(-}#>pQ(rDjMoEs)X+eb?tS$J#+pHhHiyw=Vx?FwCcsZ6q_%F zd(*adIda4gfJ6f?E1#`UZ$smgh@(Nz#cmr(A$nbp%~0Z0)0@Jv?k@|)6RPLTl)Dvk zJ=oIi-V7``#n|faul-K#7>;4OgQ)jP^l)cTiW7sm>6JNg(r#?onWqaC9WP0 zd;Oh)i3J&3q-`B33DTCXz`jxb%UW&`m&|XrotzWReQH`hK3xPVllQ5(j zm$DBcGc~sTHJc8L_}vNoQL016QH-?{E1}qz1O9ouXv&wuEB*S@KG+wZc3Vgb|IoM- zhp;dXdy*(l{!v%MFN0ls%H&t597^PlUYQ7Q82iN@ay(BJw~X*KR=Iy`A}nI>jh|rQ zPwTD0v{mf1hGFE5ZVc6L78C$lo02=NQ))jQ@P?tVm=VDV^Jt_8Js` zv+f(<&L_9fELu1QiWqi&Z@b=<0MDl|t!4j0Ah;=_LdJyaDy%8tf5|RR%QscLpi%1X zuAA}D|4|7^g>l_+VxC+qt5HsYVZHo;0d^Aet9GRLNz7=j5&&3RF%evF*1L6@#1x}H zW3Fl!IW31J^t0dFK~6KX-furtJ8C2;x}wzfY^vs6(%kuz(ZgDiuI-diav|F+<5pPU z)Nzd_rX9VnSG#*I@8f9d|JxPP(lpTi(UXx{WZ%AZ$v2ZJRVD7>YlxIWUW)jq{u(>s z3VNrs!Vd|3>7;NkhZwiCv$5Qy{ah^;2WExw%`W^*L~ENbvH7jUHMa@}0SUR5C^oLe zcCJjaN*Zu=NmaDG%yY)r?WTpO}1?i@$YJQpKT^AE#0fsIyKA2gZNn5(u zr%t~;f)eZdwQoKJm}fhDZf|#Htthlp2lytU`yE0H^9bJktMl3uq-ly`Lx4k=7GwS7Ccl){OWDHBlra26 zlG&Z^h{6;KvoZGhq*ahku8K+kt1?W+^%BId9zVwZejtF;Jr)QP8xAE z`sD6vYhn?S-4)=PI^P!WD*l+!X+WhtP1*oqf zlcvnFbH*N_$uCD!rSr<>9e9RH_-V7{*7p+%s?rq1qR3woq_|xMPOPx1m37OMtCcr9 zcJ51-Yz8OmnjQOA(&U<_lfU;E5i=swetnAUQ_Gu2DshYb9@>Stks+H_5E5-p*(lHiylSNAHS}F=p%joxfoKz5= zOlJ6S*x5CGQOOu+;$7wKbNs0#YO`#X1HtC+wH?=sUv5`BfbcTh{4oC63)@-Ji^@5h zpHGyU!uY%#FfV^jj!^YiLVW+pr^0Zdq!zVkGCOAa^mKGrN^$r2dwcu^V_;g9`3wJC zdbv4*TT|8ZDD0#d)80rGD~(?Jir2GmwgTFs-v8N&Z6?yFmCx^OBC&Z21`eR$p`|@1*XkA+4-1$1D`t_duXrJ}+&5@IzOM*2=TS-Y&CoJ}sug2k z{u@sBzj1Z{qn_Zgjy0AUW!MiY-DNd&ip!MPgX`ws%#prs*Z$;WB$-K;|Mkm31t%w( zbHU^&F>vN7UKkm2 z9@qlN&IN#pq(96V_2j}pR4L#@$BV>QYqh_((qKY>VpsZ?!ba)|$BWmjDt2ie8@KAd zOx!4WpJ(owDKafoc6HBAYh2L(080T%-%}@)UKS%-A{*O)tnDtP{&5p$i)a5)`H=cWjy{BgE zwi)Tkp0YI`JYDfB0d$u>?4aM!9wF=ay+kx%Uf-Em)(j^uf!EJLF_|?@Vjx*iepuTh zF?brp2fjFTF)ok3xEsW`^9@#~hWHD#9gNSMFXX@pxg48q_F0w%6f_ePN$zqVePJG) zV;uxv*!*tS-qCF#t$c@ozM`4@2*!9||HOzuFckU3{89=SP`ygKPs!+egO!Y5@y6IB1^8T;u zP(RX#>>l;B6_l)JPLsAXus5$gT9)R;(M%f8bxd4dCbOH%Qw{~Z8)RhDd(1{GLAAc# z;UPI%6`XOVvCHzfZ&fN$_obTt26Nq+lS_tV#G55+?&GW!6517m@8e8(i;D(Hf-YZ! zaCJ&BlbcxF^<$l)gltsfCot8lQkp-3+&IC8=C)1yPZ?sbP3Qv`TGL^%bq6^6M=+6- z%V2=dek<;W2o4VMfk=smI8j_Rd6su09yJUG zdQMDMxO5%)i5kp%dZSe7@5kMau=-9O!v#Mu(sMLjW)e904jtNGYPaeiRac3x z1$A$5yJRM05Q;}Yq4gq@0UQq%BKL&>Y_kGkD)!{?=8uO=(>3`rH;o_Gd|x`HxJrbK z!9n~`4%g2{1bU*XrfH-+vn9-yWJIk&o0*8!7V{!61!JxJOMV7J5f|tfG;~n0CP*R{ zIl078m=)l^+X$hVZbA2M>^sG_g-rTMw=_q!`JLJ;&vQ>iQ<#jOFJgr@?k(61%vF&j ziTl)ZX08T)?eGgHXI_3}O!wK_^qv>yk!qY39;<(V&bVt&P)5raZZ2tO*X@7we2 zY*0lg&}JUWw+XwCDPpek4QLidIb^qCZojL`v#5ly(` zn?I7Ux(_KHJjK92#^0x(M)lj8PD_Q%^up{UC3Av1#C`AX1O2Z2j{nQec%Kujj|>d) z=06!(g;az*c#}QRKYEiF&C)R)yw$w#G~+Gc{bbZbveCO{L-{37Up^!v|GCad8Q!vI zzaF&)!Q!{;Qo~hgCoqNW5Mi^;>S-B`YjZ8Hg_oQCd0TMMz(uF#&*o#32ZfFi?G6(! zc4&mv2)6ZkkfzKIDCBgz_2r4pFx1+pMMxc~b{q=_Os=sRj`S~^nfap3)4V(OHsi74 z@b(5j3V)akEsJG%bU0y9=a;_H?c;tLx^J}s%W;Pl#n?&Z1PAlNXctiE<7@PfsZ_x! zBke;SWB-6xO9{(6RTDZoEhdIE*+;LS><}p>ts2+w@ODSr7_uG;f0OgjvGV=QRg!M! zS{v8XwK=h7s5Z!{R#@>tV}Oau2xYMZwS%tJjm!@ z)4{Zl`<5XpBh1L1U>~U%du8} zBR|xS+um{}o#7e-SNsN>XbdC6S&dBv!biMk#QEpTNL4TKd2Q&DbhHeszg-HqlZl0Y z1CeoA8k5|(7TJ2ZHZ$kXjK1*f&bgToEszZJ5x$~fPyac=w&d%y(z`i4Hrp0~ZtxCM zWGj?TiwRjiwjGeit1w5voXQi@sfVz3dPjOfv$iMVtI8bd` zTiM~uPfP)YzQ}n;_pkM_kiP7BqGE~fO}&rzl@lsG2j1OJXjYOhQ0f&BK8T&VPcuzF z@g;#&!zFqbsCdxUpjYWCLfy%Cu8j>r69-|+NBhGrY*|vPq*XQfu9hC$putc_=n1s; z8{%jBkbl(z8998D2}znh`EmfSn0mro#0-u0YkaQVF#`e0;z7NzRDKu`1dt!ywA}^T z^}-%z@5fk;jG%97vwA@suHo4}NZWzLTf_3pcD^sk5~_VX@Gr%jSkvjLd75B=#LEjl~AY zW_xrg^@O3VEPV*S7S8-~%ev(bqSv2aIX@Nj#GRH;rksN$!uyL416RK%JXublK$-4Xaq$S)*IHUZ-<*H+UO;OQxdLWoNmKDl zB>3zam|arkDhdvwP~S?upE(xByD@+4M+T|&U0Nv*lIT)WB#6n<)ZYlbS)SgJd)dhc z`hK2F$F6mpwMMw-Xr(AJV6_^-HF9xSOiDg`=2=pa`!w`KX0A1R@v!IyBp8UY$j{i+ zsFUo1^an3R)vClq^7P?H3rH2P!u{k~6b!ZFG8p<`Di!V&Bv8(!z%(cWT}>gn#RXc^(Wy7Wn6&|Dl| zm+s8KxzmtZBg~-RUcSH4?!A*0HzAWY(Ev_t7>PlFxScNnOcY8I8Ehs68>G+5qFqo(H$w+v|!2b{mi>-!@Y@`2e&O~3Kb|Jl(;j6Mn z;LMTf8hQicbsZ9;aC6nwdY4GPv@$!JFQTnlo{8Rt)7zLQDVAhLUG)RxnYy(vL&=wO z2E1L~fKyxSeW)TyDjm3Li@tN|aKHa>SI=8frdx!QYv5pei#Dy(qHu8Z@(7T&brXih z+dz$XbDJ$~cCwuMvnJ?s$Covl82TfUoU5c`equvRb!lQ~&kl zBrK5T-{uiF+7(nUWCh*3ita;?i~!OeL`voI9&kd7-yceb(9%3fyORA7B%fb{-brr9 z&6KPCMrFrysDrd??S$Ghc#|BuSiR5%g}pJJEq_HEfNLThq{)|5)cVqwCA;{cl9D7yfldF&M1nC6zm)QI+Gjy4(k9cnfWoy(S( zlvQh>Nhr;3f!_+AG5LMtW3OJQ+^v?qY|kC154(F_jP8DHm*akND{hAfM2P@rDvr89 zZQT31&UUxa&Y(Z^60l;4b&{{u~hGJ+40V_BCEOx|Kz5HrW^y@OnGke zQIB$Ur#>{`@+Z;THWAx9kZnrP|Ok;B#fB3 z5S^0EgCRt-7sN_;cgsId#ZhB>7~!V3%1-?M+uC;L(0R=@`)1eUsM6+20a*P!S;N(lr zfwFPlNbYBw~I~zg4Mx-JjPKFEQ9|AIW-^5eCa{sKlXC8^!CmT-A zFP=N7eN3v9w@dN1VoUag0hStCuzqzG!s+|^_87Oc92=-Y{)$w5-Z^DFD~syDT^~ zYQ4Zq(lK;CKl}VE%3-It<={!vJd3eSiPC4H6`Bt|5SuRj*;N{MKK+V!C$(dPRZb2H z3^>ariN~=Jg54X{fVir-xzkyvdYU{Zs+6s+=S8rK{K3v(L*)Qf8F`rjTW^Y7~g znUtIs+RULUoSKOzRTFSbG!}sZ*Mnx&zE?l^tZkA+7Zmv5m@3rk57s~@lYdOZj$l%- z4FKbK`j7DXKI8V`d~Y@i4Wp6F+y~ebyD@gaIwA;+!lbwkndIA1j&~68irP82vTy1+ zlBkE8QDvJLPZ~^vKf_PgLy4P9bn~$1R`Qi*7m^%Z3F%y&6jE4i;&of1)^q(3+d&EbAmWCT(O zgZ`X?lWP_{J~;cc@2X7Mzhpa4r_mCm+&8K%FZt_6FN+($yKRqMur+tZ*)(fN@&{x= zmq_41OImrB&^IW6xFTkY~B9CjEWbk^QZR(A9FXFo&Y%o-OoMQtokvmTzP!N?@6wjIe=NYtA+8GO(h zuuW)-tKeGRBcAbfF+O_;HD90=we!cF~jn8*ns+)e<(<*(d}m4X5St!lFt~Mj*D(A z{iu4S{oNylwV<&xK*+lNi&0BHa#szbZ9Vx%CxJ*w6#?sRASwL)Xy30F1q6pzIN-pt z+@+!&%1J?C>9WMW9appBs};i~&)Ne_LlM+_l1b`0Bu#a~)Ft7FlFgF>4ccwqkPIcgwaC^QDl~ z7!v%_vX-*Uwb2I#Wq`Zxt^AhQkG3<;%Z#lLJT@K|I7~vLlwS|F*0gQiea~VU<8D#? z42JtI@V&xRNN2jf8tJBsz;BaPXafss^bcaE*%lT8x7XKXZMcS}iO{!vjXCir`vxS0 zHqto);26P;U95Ps1?)fHLffZ{_8VH?hQdlOmw;`9LR}&0HJl2>KE?wv66>g0;W%A< zzBC0yd07DQXUdtK64Rses5Rt@*T!CQt-u6!J#wMV9a;_Sm?r{c*#W;DfDA&B^^P3< z!+k?*{EwfaMSj?N%B1tt`NpefVB}a3y_Nn&wWCAtaxyrn8rV?Q%8^Agn_aC$yJ$?5 zB~V*vtG>KAyG(GfxJ@Nks8_9aL_bedXhy1=@N(Zt8N_M}NljieJkQK#s!lO5c?KS$ zpvUKFiY~wL8ra7)KeB_49$Ze4+PTjWzQ$_5OP$ne`!v=(ulYNpp3UZYz{^l&A?uIL z&_}jBu5O+00%ajDEfduWbn^aUnC`no4W)%faX`Ul&>_p|ri9{+SQX(;-6FHgVUK1u>h)fis2_XmskApyt`|SF* z8d5oPMEeaBErPM8K%qEp8TebBtR+7Qo?`59#|4TN>t_hm`H>4%74Jz;OVwGZsTXfa zb;-L}9*5!-4GVK-2{NaLoa<&1c$}ECt{jyd=U$^#pUqe;X0vK9ct`D)mpJ&0HMHKk zw7eG1J{bn8wX|Dj$G)luT?7^SL#yh`2?(uPST$w*E~m$Ba2?d==$+<0TV5z?X+IQ0 zA5uS^@hbBjZmayJD6U?4N~Fph%~EIY7gSzdp|ZHtm-GZQ^)5>>>Eu&E-?C**dQ#0? zXx3VqT}I)7LZSM>v)BL+LCvE2bWe~PBCR5$Pt-28G^aSChwh|NaowQnftYPA``o?> zx6z~OSKzM<_^~YcyncKH+6S?^!`!aJj*;&Z3}qG4px=RdR3)Bf225MeP$rq5Nu6dA zx7r$O&xh)VaK;+AND2sgPO@^J>FG`MGSAVaX=al<3fWDIwB+}zzG{EjmXj*82}WfE z99XqXcxt+aIqM1{8=QadkQaub4?GlPMu>X_X1$SvtoT)+TdH}ItJ9}jLZfu~3y@*-OS z6n3Pzm6QfpEOL_Cr|!)$2~}C5?=^)Vi;vF{v5A*{w=wG`sVt}SArTYSUkpp|M&X^d zqHUFRw2nxFtSv%d3*MseDaC(dAn)KAWyc?RRsEtp`7ffBP~PZ)AP^|_JinNYY=NPx zSoUpSHsk5(aY2s4ckeb5Af;4tXG`EIM9xOiAugfLx3lGWq@Cr{rb5j9zWzj3>5oQ_ z+R$M|Gol&E3vELg2a5reTmZj#wjhGw&qf*B{o~>C$K9Q@Soyrr_~vq0Ygt(i^RTq3 z=W3=^`kolU^v4!Mw}!`!6yfrF+o~CMie=%E1AXsi>SOsDr^dl4rStD?D@)$9Ye$^M zX4VzDIJw!o$t-7=c};Z|{l$>fJqw$XBT94<%H@6x32z(smKj4@8A-qBhtirc;Oaz+ zE@2yB+j1w4DRQlc4!_d&7s3xHCO z7DZE9&Ch^lTmn&`-uepnY?n%ylQ9b69`gwt$GZlX<9*S z!ijO?nAvPZ)*lD#TF6&6m78ISHivzy1tfoi;0eS@6eWW2Ms%J4ejX8o4?-f_PLdx+ z551UdvvWB|v;d=K!yK?1wUk7|NURs2yV}u!k9&unW!tLsKN+xU)hf*IZ;4{a=oUX- zCEUJbNM)+A!rL8ufEZ{-C5ETOaUN*eVUg8^tn{9<|DNg@bY-KY^|YBQ05FTN(bV(B zdeJ)^o-*5<-z60VH7MQP*iRu#;3lG=18#jW&&Pv|bpxda*8LOT6A7{0JjdvW@YRPS z#syjxjU&90IUUtY-7CqJEH<))t6@@CsQU~+!$8LaZX}@GV&xPU`I(2+vo5JM0$5bjLu3oxXK?-z|9VQhF zgR4(X65kQG*;yeW*5wWKVrk!eb1S!4*M(;ExSH9NO`ntbg$f!Zb7r6E4(a|dOO4;! zcpPLptyx#e8@n_?a1cvTC&p^;Ib$=7Ge%>P<-+rkzvJzYwZj2Sgi!PclqlnDw>K*6 zEc^jj!a#ZP_I&BNXR5Z(UX31Kt!1hV{5t6Tq-_eGQ6b%MnE2R{LF96%I^S!|0a-$$ z)#)0r%9SZ{V9*n&-HA0VFto`oIX$qot2*B5;Ave-anL&NU@cdQg)ZFBFpiGoX{O0| zxeKvCk|_tJSSr6#8matA4^}VS+_PT7;C9b&=P!f0&_1J~*Rehlp5Ccux=*}q1XK1%-iHdFSatR>$k^U3fD~(2F$VraBGh=zmycE!Y%AI|HS3sz*P1$5KE24 z7Zz5^5*sINrZ>NrkzgEBgm+=;)dT#M$MBmoiAq(FSI^I(wijR$=YdAk9gz zV@&O&eJ=3?N2qs6Zi_%sXg(jSXZ!kjL|tTxfM0#41kvS2R4UuV@4=oT-cH{Btf&5Q z;`LIsC3A9-Py=J2qPuvBE-9g8G_3FW`}qANb~S$;id648Mn$H0S3!aH)0w<@{r69k zCV_JCENK)UdFGv=C2kEiJ3V`M`3Nw2>-H@;C9apk()Js?X0%mh*fpTF6+C9ysE2P4 zhp-u%)C?Hn)rvYhfsL@=)p#8pp>}(0MS6EGM<6hmR9f~GGNcv&_g*}GV0-QQ-rIT_ zk?KOzNz%jcw>@T=6fjp3rl}na8D##wP|BW+xM5xzMswsuBuG? zXb)g3FG6-t88RN$?K~a$R#Q`Bn!@Lq<-g0*1=(kn9odi*vYnF4|@$cRy>i%D^JhG zazZV$jNGO$o~u3`P?#YnTaDuQkL2ORH|Y{Q;(QX%!Kl z996E;jAVIgX0>HKpKXy3p7lIMo~IgR@lRHb#&56r=xi0R>||_tyZl0rFfLa7G<3z7 z^Dl(>GmHwgc($>{O;cc9&Z6%+(@%-cl`)Epf2$Wr@CyF4!e>)a(4zU0l!v7ywRc-j zWyi!*+bozXA%{1TpWIkXcg652(5?~Qck2M&ZJ9j{@*yL-t&fZ}ib}!5Ev0Sf%xdu0 zonEx`#Gw&#eVay~bkNG8X?ZP85H)ENw=byQ0bl=p#BIT}D~8>Nk5q54-e6%!EH-`s=DU=Kj;@bOi;Xl!a#uCQ zKKE=9>u4udyJwq{iZ}RHc#r=6GskH2mRoxd>c>LJCt5A|&1RWrd^R%dTQ3!pjXvD5 zR(qd@yTG>XWXOwC-g>AJHmd>9rND4gV^fnPk--g3=EyG*UyI1Gd&Nu5?L31u`>6zb z7Xitj8Ouijf+Tp!B2T_R2JmZvxE}`khsnNo1}MKFJG#6=iU+?kRx7lX`0-KfBcr;h zay$n4e_nw-2Nr_&FtepQ^9X=L`ad`Xq*PCd#8U0EXs)4!V0VaKOjzY0JGnK*6@x$6 z{oPN}s2Y;|aYE-KmL$=-s)oJdwL}8=kJ_nBP0QF7lRvL#g1~eKKh_65KHGWO*(3}C zoAc4R@(F}j#_V!_836WXs?+?U*jB7X;^z%vvPn#aNoxm1thIWsXM1N7w>#UqTH(b% zcc;^NN#~h$!TH?mJmgI;SBq%nPKjZDrNOm}OhQ~`{KSRkf~pD~0@9O;6fD{A=K9d| z{jqqfoDp@$P^vU>n_O&&lSJ~cGOOkjVi5+|WQ_}mLvo-MO^p9e&mYbSLNlDCkE)B-X$dg(hG{=4TbC?Oi^$>8iCA(9 zx^ZH2#St)W;j7Iq?&flFjYOIlbD%610(T4}gZyTb55rxn|Ha!1j(WBZWz{1EPBr|@QoQ&q4A=^w9+{HXo-@CYuhUzZQnhj>VYFAK1 z4TkDXL|j8BjR)v^ziIFo$}sL@4S4%Gr#Nau`8L~6oj+Kzy~&=E**=o`(5u?vuKJ(Y zd#j*0*68gwI{^ZLkl;>mcXxLSIyfZQ-~eWFtNQBg>b0uhwbt`{Qm3vc61fiel1}qhn%tcUf^8OZ27;$d z9!*DSOX^!*mgUow1AiQHFw!)B(k-$5{4UW3oLZ4b8EIcdfSvII1K1rF?{EqDC`Plb zV!@_Gxl_z^KdvGEZEjtXjOWO+`#?BE$#U3_>>nH5tEhRuop$aPcN)i;w`1=T7m05z zNzf#e%fHjr#a*c4nc1Dlds>MrgF{$ zmtIKj*?|gWymvxrO@eaVU0{Ul$Ss|@fU^6%>eHE|uO`3qOKaSC_ES7Xk}k#X*6c>K zpE(i0yO=s}=A@jUQpQ)AV_gxiZk^^E+mqJ9EG-8=PIvUX>TPE!Y$AI(!^6Ij9datn z&pro<=e1tUbTtAHTsW&Iel5FhfZr{fGtFMXl3_FsKPvgA0%PV*)kc4jL`@vfEVM29 zs|;uxy*eNzR!SSPCZRkUM4rzb#ZMQEyg{hfWc-d*&~Hn3T`8#Tm$4mRUIL9ZgI3zQT8WBXRtiixS+u<5JdAt7PB(KV^wt}FA@*$tkD{+w>3f#T63Y&1#!d1#?b%lmTRoYIZk#I?*E z`aT}?ILn-L=vcN@2t~Lbj}fdbwxqd*Nk*qhufX!6ORoa`;FpoAZ2W>Q1qIj%Ue>IM zyvb^>Tn!KF0@OJ@z_JV;ekP?(%{?znh^bAMAOGPxG?jDQ4d&};%FT0ui(UJ-?X~rF zQ_G>STFcYbt_F_!SMbxiq~*C?KKA~}p>II12?}HDA1(LF##Hi`Mg=$iwmi z!Jtp>>h77gg700!_h3i$=9f>7;Ikp#=~Q#YN#2(d@ZlONGH~|Xt3?ff{m*xiPQf2d zt|MBb#T!IFMwQBJ5mznfb{KgExj9z+E)x`bkwcT?G>=~|9?8^;``mAXVwd>~p3(EC z=*4SMUMGbbOJ>qXkYJpQ+Gap_(#kNYqE*4jU;geY>cmfOg34vC%Ek~>PvWe5+T?HY z!=5Ewka%VM9*-epXzt;q_1T>ZO9<482f=C@Sr)o%b-g9EU3FxA#k(T1A6E2kjre;8 zdv+0xDq&DMNw#Pk*D=XJrpq7h#P@!Ht+jaSI$#Qng^n)638uPEuJFrKh?Gd|z__lw z3uCW1sImJ|!#p3wH)2cMeIatg=`X!@9NES%QN^d>bzGx5sxH+0m)OO@k8F$8-fy2i z((jqzH5_~xKF)9YWiFK{{ zLXe3i!gGopbW8!p$xA-8?bb-!_p(rcZ{@=|C+C*Oj)InBv}e-4^E%70K5ov0MQYq8 z6vn9Lu6FL~8ZfFe+v}2z=ah{VvS)}h3yiFm1h?J1D8T3Ey<0x5lpPYs2c@#=*emC6X2Q3q*^K{Iw+H%bjty3%m$+19H^AMJ?BuRDWZv^> zi&BwMP+UaC^!r7TPtC50TU60Rpf$B~lP9<)Qx^Kw>5i)V+bB^@UfJM_&S?#FgN3^hu zNt|Edn+jLl2dF|yWL+U2$=#BjUY|6*PW6E$#i}?H=)5Hmv6yim2I;l0KD+(1-Og`r zAhe!R_tyCAP#0a5+%x%6t3DeSNjsytYkaTVxGY>V5}`5rXZRT3R9-{LiM1KJqsFB) z>|3go;RSyBQ?p&;ss&HD;#GXHx$B?0yo}hb?#p)l$?P?GShGD7c!Rr{?4h)|jNV7j zuldz$#5i3SZ(Bf9Y5p0kb)XW7b$Xm^k{e3(dkBxrrWu0W^L`D5AAOBsD^zB}gi&c! zS3z^2%fa^$3bE#%+IWVW-nfE=DKoLCXv)3 zD@82F@hLCj&DRq>Cs38R=YMo)R-#g9=z@G_RV|M*L+t_&6lJe?{Xb>?_pF`xuW0@J zIO3JgPe2}!46MxN<3g%LL5q?e(R~fBHP-yv^UUD|V3ui&GRg7`6kBC*3Tr0JZsd@b z%_~EnEJ8F2g4SvZj^5G;IdGq6JrSU^vkzzmJ-O`k~)s(qkJ&cX~6<< z$D5#S1zAjK^ym9$%<%#+EsH(EvtcU)Abq&iS~>Wo+VvVpV^zhhqFijJ9t|2rNITYM z3{p!;7f4kv7gVg?n5`GK6o0IBQ{oy(C-)d1EJCUl7b@)mb$x1$^z5gfo(u^ z=Yu5let(abJ{}70a^D*iE*hg((SgD>kuwK!J*8wSQ^E|p>TrXKW{ra1TB!6#-ryBs z7_iYz6d@Ggv1I&Ze{Px8Vyou-%O+?|u;}FF?9e0m?>U&|TAjP1jG>Gp%=_mDHzJwB zy9i(G1|EtIY3zj6*6YFVxNH&*fbG<%9c~qKiCG6eJx&5vgS3AJspcQ{mI_SWjA?&u zO#Pv6@hUCMqn}y*RI?!Jw_Y2?-AHF~Btm@NjBaDu%;4CL-(Y-wu5PC>ZE2IFWxAc@ zH7tJG`om8l{nTWb(i1?41_G9S$=L#^# z@LBex#&BRpi8eR0;aVdHwCzs!7uwZ;6{>~ZN zVQQR$|JyL<;dU~Jhe$Tz zuWl3hQm`M1kz(XDVN;(^(kgcQ(#)fL=al}svcA=bapfKqOu#68)Mzc zlH^DcDE^pHvwBl4xS!!_SFJRQeVDAZ-%##X5p-NxR-;$noX1ajKMi=0YW`GpOg2?@ zMEGgQfgzi1t#=uBtJMT*IP-|5%N(ju1Y`@_aw9*=uvZb%!O6g3{ULIl_YY+E*4I^? z63fa?57KB~yydaGdwq8DNIn;83w^|D!5#M;U9fEO9DXy0c%uv-I~g~26{XJDE{fOe zT&XljZ)vjR;F(C=@DK*IP*yu55DvjV{aLb5P{~mXYfXYZwwPS$)t{O?8M9ODECGIb zudcM2!s-OTEX#K1O6=OKVzs5%>hl?mi_JkUE^(^|rw6$j%aDTsFrA`5Ok8Mb+J8Pc zUJEBS;AXy?Bg0ZS$dFZoc6maS@qpISCar^=ADLj7S*wy5r_~ybm+;g&!RpW-(gkZ6 zx)vEebW3We;)As!Ub&ueGmrelue5NuX zx=N?4bU@^++c_S4;gZ`S|+1A(EZsmOrg65Vnl|i-vrQIr z`1Pvc#1qUOkjK0YxHwy5ylG9?cQbF6^|S+<7h+5q=C6gvAd>DE-FZIssFD|7XKqYd zk!dadAnJBtWI|f@n>5KBA7U~_;Ef}*S*D#9>^sUZwY!u5mzTJ~@^E(W^(vC6Fj9610AZFqVyi5{w zgUytAHr^!g+Zz`2_5iMMKIS%Lep3c}p16X6#TH5V%HK*r^E`xZwq1}CX`NkG)>tX} zY^mP{x-$W)<8HdT1!n~U#jRr#0+>1C!&A|P<=Y@Kb+zIv)~ae(x8xiS44rngT=!)S z=!iX^iI=6C)}`ZlD=%Ef{Gi{BrrsZ*lKgC;jQX09PNDFOcgj|d#OMXFCxJrw*C(dp|#gLy{jg4nNYo=5{0Y4iQX~8U#+(Ia6hLj#Q{Z({@ zn=7`ZL*uV>BGkjzFn^xp%)NA}gbK7aQO}iw-h_t&`qt6bqv%!gPba~pTmbq0be954 zh{}=!!MRy;wrLi@33|R+s9heX{8(wLmtu>Z-Vlte-ZDz;otzV@K03S*yy`Vj-h>KHc03f^*rH>WX2@jmPviOPezDdr3?%ym%fW zE}M&E@|9VmbpsyKC?g7M1^e@x`ca4119u9Gp>7;6P`L(U4Hhv*1{Om2RA6nt=jyZ{ z^Aw?uBgLtXRgMI3ZS(BP&(2yEUsvmDc;(7HZA#gJb3^skk|)cD5({i5Lk6KY67bx* zBw3qD4tLIet;{9|B2D-7%8%Ms^-i+^YGT6KwYrj_PJ%NExv1O%zT@h*vQiT?PAdE+Ns>{OV4mL{<&K+f0*CrZm4f5t3p*266HDy%u5ql_~ zf(;SdW!~kIGC{(oc*DU3U&a$l;$C$g``)*I=CLIn9;sTp>f#yWQ@3=uW%QI55PgJC zGdUYp5^`tka^eHAoqrv=c6#1$Ef*9PPt>UD;!B9yuwe>hyYsmvxPZF(|o3!Tygn z*6!{lcZqNR`5={WC=6B5ef|05WTjKYa~tU+ycKr*!{^Oja>l-CxJsS0L28dlLlAnk zbhlA}{5Fc%z=o=;1siOFFk>UZ3Y`4{v)S}z6Md7ENM6cVfy(W5imp;`^E>Fvi1%XR z_N)ym{dX&|kabpc(r~VITkw`7$^dnenX0~Kr_(Oez|yZxWw=x(?KT(odyql-p5Ozx z>2cWmbDwW)yIrP;Mvu8L7FFzvWk^$RS)4ec^-b7m5BJ z>LRRl*Y^D)iWMYXIO_})=;uJ?n%EG(wCIgKfm?k)Can}2f7^;^(VS%o(i@Ki`mweZ znHdm9?5p#=xzftJLWKR(RZERTq34Bv$Jk^AE_)IhX!iNA!_DgtXnCV&#B~P@ss89o z+AH@OMsvcDHKjVi$Hz48Fzncs0C4*|>yJ}Bw0-JoOp_}*IXi7we9O&82NzHx5;{$M zODn1B6y;*fNQ=)tV&YE0dj>B!+9Qs5aLF*W-V?RKwZqq9H115X#Ut1)t5(MTG|j7;yi36D)!;QNC$PYNdBMPOC5 zx$bOIjEGvsh?5~()lq}lUDJt1qei)s%qY?=5?)T|$(Q_kGRk>d0Hz|x?uJWU8TLG-29e8CXpGPYB655D zWYgssT758N_H7`IJMZFn>E|qsdDRH_b35iVv_S#2Hl3>}EBBbfgFSUb5=u(UGo+YMI z!T*|0&k)-Z7;$8YH)M(Uj*xbBTVH>-`j!5n=~x+>8*Jwb)aXp{3qK^}O3$qxILvZ< zDh@CkWjJkFk;L1`(pXnWX6XG6U$@`k-g;!D6MkF!3*r08XHJQ1aCQOL5Ug#@+=@OQ?UZ*4T|RDD z9Q>cliZu)=bJ*5pzk@j?R;-)OF@B?8h*ZZRK-C`g+sXX*C|-x!MHi$yLM?_~G($e0!+v;Xjj z7*aaS{a$kNn}L+8xy9{@BywpEGfftN^o8tEO%~;(yl!=TDI6s-Jj%?OD=u`9zi9p@ z>JPPQS&Ah5>DM#Ks{bRn^#xut8&~ZX9T6^5KLrxFCj&JT(%D6-(-Awb~ z52&dQvEbHIzm(`9fF~IhP|7bDL>+j*Y9{|;kLc`G|5EkcvOI92A>eZ!mRJ~wHntx+ zbDnJ55)~o&Oz>zw#G?5%z&^-9kcK&AjqiY{Qf9n^?o6!+q;(U*0q~3L+{@|)W{hk} zb+!fZ9aSO^)1$$I$nC*Y;3X`M16}4NtNstN9P4gAConf zsf{@|9DKZVEdc)DsnFA@w;T>!XM3V1%L@x!KUJ>S>VV(!Kc-Eblkp@}rHjHwC{bGk z$a=4{*mFX|br_>7QHKPx321~Ijwc=6EB@^563Fhu8~^mDPRj?*nHOpsZLRCj_%_J} z5tGO%BTg(3sbdz|b6esMtW44I6g*=OGuC1u4j<=P%dud?G;L+zkG^- zy4roRN{^`SUrWKdzw2O;r6UFH)P9NLBLrz}>SJ|BE;5uGU)apz%hc(vW~A&YkgV;+ zs%^I-b0cHAARHe^XY`7o(Cdi-sDgBoIyTenFX)F?ce{mUJ;BXpOO6VhpWWpXR>?+* z2actmf4(#s@e~t9Z!ZtXH=+m4YM^Y?k539Iy36ExY#JN%c3o~vYEx>pL~O#9u)8m1 z+mF(iZmwqIK_wNPJfQa&-!p8AxU-DZIFk1z)pJTLLfR2q!ESj;8Oh2dDY&dAgivSn zXSN|pRy+zL^LqmuK4AF4pqb8q_k7Pg9m`ED+P-U?o9PbfVidC{l$AO5&~|p?je(rmf+jis1cXS zs}0jeM%yc0F7|CJl=(Xw)CgPqttlA~oi-HuHgrK0osJtiF_1#*fDMU-$;m;%yNXHqEC1W*W?|Vu&SpFFxbF1nuKTq<`-v08+KttA|;ab!e zpifpLw>X=fwLN$wdN=w6sID1D@ z(!zrEC2{d(FgLP@sMIJ$4{~9IcES_b#1jt_6DYl2IQ%E-f3sg~nXqszX5OwZgSfCb zwR<(wO7*l<;zt)Gkri)q<)YIygZCtn_#6n4LEj?kEu{MVGu*G3fyTN217EvPz6CSd zTKs=_`hQQ{Um^AqNZ}NT$uWt^S&4_{XSlU*z;fYbai9?kqk zM`d?avalwvSL!LJs;VJARqMn>)c>RDDD-CAXBi@pIl`enuCW4~-ZtD@`FaZ@r?mHp z%ghIu`|RM;q{^`WjKZaxLxXdMBumyg0hHdiw^(y%xL~JV9vW* z@Y*>^W=^lY3m7*#=soBf0vrDnaZjw+7Hu``u60%}N);Y<61%@6DMu1z1G9~c7*o`^ zAJ<^whLNYxe#8A((o+9}_4HP`R!>gJj)tzcb5`)yI76xO_Oqm*1#2VqkB zW}%D+_&G?Iz(Ma6E=;H4x~#DJ-GQZ(ay{06pc0wKwMOlcJb3@s#(%JOU1Yl^b3_yN zMZ`e#cSc;zUzy=yYf}P{7gr42qv6EBoN$5{&*zNZwheyNR1o-GsnWxLr2KS?9{WdR zm2W&h1Fmeb0!52j6Jna}S-f=jWTRCe?;Y~*2)m(3h);sy${fIA2{Lv=ZIjK3Mw{QXMAI@x5BIrx#4Jg1ZqUM?&c{vzRM8vDX$n z(#`je+l5X7+?-i9S49()w3OuG0gTHN(cg6*4J4jr)dw3zbnZC)$d%Hbn=Rf<=bi}} zo)nQ6H{lTk-my8&8n0x}$)VO&*~7B5SsSZtNHh-<5|lP_R!s^JDfUd~J1NL9Tcfbz zT*CRB>5+%wUiUi062~il=>Ql5!#UYnFYLNt%tcnF(k?Lw zC}DXtsJr$)W1;!wFX}Mq`f;s{kat}1s{p==Le72vzqqd^L(lT#Q=&0S>*k1_Eb=5V z`9YReS!uv@&3Pf>7#S9^yidv$tJEmg0n2L>r)-<9`RpTzA62>>_DLRyI-pzi&smfV z_Hh%KD9}>Vdw5FkkL;vf0X>=S8-VfK09JcmPFdMBsud=ZL)3ho6_2 z@qMWuf91bO5{qC_7P8$JbG+`|6i`4yw@4`Otw6Cr4h(31`1~=iz=%r~?A~y^ZsdlZc>3@;dkIHS`vx&&k%*;y zHwXsh?D%8-xQUoS@uEPIl=}1!%HUWKS(n|#US-P!xbbUx{bu5kQ(aGzdfg|LyS25X zfwI*+VhOCgxZT|DSUFbW@9;=Xdbs5|BTD7!o-U`}?xWWDK90MqA-U`HyHWTD6S*Ev z%cU=Dfb8++@%Ix%6k=R4wNjG_EeolchVjxz^TC@J-WIPir4U|(ePtG(GsPI}`jbM) zkKCH`8{=lKXmQ}nB~bu<4v>YteZC-U)stg*0$;NFC^7@VXh5rw6a&Ls*VHdl_jse@ zWc4j*gQn?LJai9T&t=h;laz*E$8$la9dRt@@FJ2}4$t>YK4IE0Z}49Ax%We-nI>4##LGA-XT5p)Ke=A_U^9VRk}m{j&g=HlVO z(kmD70_bUw4j^ZNT25+sWW%Il&!Gd*rr_Oq(}~o(5=Dd1YNvy=YkK2Bn{>3@UY^ac z+FM}bm^XPz zVz|azrccgJpMb7wu;+851Y!!mW+2D9gYh55LwB8>JT8tx0GR zGce2`CnrirmLy=9c9|fJ(>-L3aYBn8V86!a?jU0RL6HJ0LpE(0WIw%~_NO3ds8N<| z#P_?Y?|;usVP@|`@9i0z^3Yv!2Ddsae zNd11*k!go=A@q=OD0gGKAFg3@JK0(2I4-5?n7m$&c)Q1unAr{#vNtrv8MbTxdq!xP zo)08FN_olauJ^my@1Bp9)Ad?UZOEaX=Wvet=)`qTRD?SLo#1x?Y-sFkgMX>0*w5ML zbB}9fd!OmqEYaszBPj+ME*~SMR%W(mjBaNhrTsX3eEo@=lR5tlFSTww^Maq-2CfQa zzV|6WUt-1GEkzP3m@hyiS$F@nUUm0dmB%RaA7{$JZkXrZ*dZy$v7FICGm$Lhp(z~?W_e8lE+XkB}gC63kPU|Mr3EHsitE+%e8hS9?V;uynl4NwCV$jZM(v zJ5z{;H#o$Bao6xMdp~mXXV$>fIExR<M3UGZu|K1E!@Ot0zll+ZG(t~3CPdyA6DYwANtHVj2KDrdVxFT#kxvAtw!w?>*PyQ zwT(mnNefC`|4|^iwyS_*n(bII_=5_5{g0tx;bFUZn3L<5_YO&^ya(|M~pv1_jr={ms{ddGoElD3ZCo`btF7f6w}p z9{83~!=@&Y`rppNR+~KoZ8kdUNk2;0u9KDI517@0c6Q0Das+Xj`Q6s~qj~unmOagf%CUGE_=Ita|z`($jXLD@M{DCA#3mYbFDnyG0nr#I@TU;=9S zvS20fc3!Y`_@&4Jori&yS;fc+KQq2l+a z&I!P0HX@kGyA7OY`mkhmmpF8vC2YdjjS${))H~vQDJkF4HAhkJdQa+;rdR<^o7|I> z_|~aG)|UP5lVgxgpnPt@ zrh1{GPOMnKi+I-UORpZ=aCIUi_b|(8=a?Z-hw4pMnpysf{H}y#SFP9EkMs64Ez#0u zRkWy9DUZD3IZu$*p#r_;R~Br`IUa=U)%`6#kl8!TOK!OZUW_xwVgx7~kS+SqSDYI? zkMHjTP7eoDbaDb2)j5(mTWws_C4qWrPV%F7QX8=hq7H+C&zW#`=O%HID*&1vuP43o zs|2Mr&HprR4zD>yYj>n(62rphHJotnY**2PyBN!t`(WJ)H2}vg_?|^a!<6QWU zDhnWlT5lNR+wS$geMlR83Ivg**uXH#z*m=VCya=UyxbYn2E*<)rYAR3QW!~yJsyDW1x1%7wr_YCq z>6H}uRU1|_j2E4HDld0r_5bU%-ET2N*sigV)#H6(Q_tO#zgO#;IjKIspz0=4((01l zQ{*L}ojboUVqP*N!4B@M&-kqeiZf6rboEBOgU5s2{R`%2 zCqo}+lgR1nnfoK1>w-wdMqD)cW;o`X&>9XFYM4@_Te2KEa_*~TMEx`zXjR7Uh~=<( z(XbLU2#4d`@270HGOrt(pHr0HZ*|>L&4Pnvrh`{*SW$MSV&ez8Z~ zg($*MklH8#(%B;_!P*0+YU!OhcwsQ4LW}ek4;A4Yjh5QoUP!i^V*fW+fZ$|?SZ0?Q zF4wJ|a20v=kCJ$M;En%(&(=-VpWit*`O0{F;rj6#qlw)otkC?R$uz>+#)#e7U$f66 z2DkY7WAAky;k5GV*J;J=RBun!^fbQu`0J1*e+r+wxKW9$hiq+xY&lLKjL05g+*UXiBVav+4O|d z=ZDC7P=BsB!y*lGj2Ju9{^^XB{%SwG{DuYeP#!cC6+FlPu~??Y4ZVTpM7_3Vo(&aR z>P^!#*uz7myov0Uax&+ic|vd2h!jx>g~lJ#rVBSO@FEAmZH!THCX?nC6suNAa0@=b zTm1BA7?9#5S*_&mC&OTmz@%TV!3Zd^v(TbqI&gc8yh*D}y_R3e-@2AGw_*t2;~X*dk_k}Hy>Al_Q; zv39#8t9T<+9@VU@Yp=6CoHr;;)afvMm2%3XG0m3LhmxyhHPG%6s5G(^%E=Hz2Jnr> zQY~0FZMoaKhlOH50y0|%MO37e42L-O-f;Z4pZNvfrfiR1jM}{aSiW(m-L_(bnme#? zeB0KLm&#V`-)yyn?;9>r)J~WBR`~^ENX<{4`Xgi2~I<1vuL=kgr{#-KN0x_d$?Vqyu-EB8D3m| zjvW%mx%%38r>k^!k;;Jaee-cNNmO78l%h&JNZVzdt`Ng&mI{toI_o+oVyvl?I-$HW zUdd-Li!mT-@h^lI>kSYC_Wu-*kK(fiJGi@0uf9e9YBzO?T89WibIvd*vMo99EvwL$ zq6Y8tTi{Mgp*$nfe?fB4w?P;Z_CHwbp{t-f9ABd6OiX8WXSw-@^lyoSUNdh9uUDzx z%0H~kSuLqvb(xyap=+f1Mo;o~0D(Pp!NZX4?Bmke&5TR`Kt8-1;yVdUNsc!Gmi-^AgP85xIzUnM)3Bjhb<4~0h1p~FSH3&P zqy8(Z@1IB07#9)5^CfGPbWu@eW|;D`f@|hep=x`)S#2WCv{S3!`Ya|=;r!}gH=t=_ zBkNj0{z0Sb9OR0!e|Oe1*LYr(nQ1J{I8oFIk$%f%tw}% zP(2fi?kvHG+;P%gSFlK8!3sE(BC$X@nKCw+A}1d>72c8rq!&&CQr&II%K-0R2#(Xa zP}$oa$8AV=l&{hgo!9`oa9%Eijb~2-_EZN=$rNsMZpQ8U5U#iRb!4HX#f>FTClY?^ zdi8B8h9ryDV~W{>Gs``qdq)+Tf0`?G?AH)H9H#&=cLmM1}h2>M|MO^n*qGsD&%v zJzLb!n0%p}Ezi>fA%(OKblb@)yXe&TJHe#xQVh$tAD9@HspExlQ zJ*e3R`;1xvO3JG_gT+&|gb(MQ7sJ1hKw`|fFm6y1gMtwI|8cfyy8lKstF!k%5%6%) zAY7C^ySFtttbb1MFYhxqXW_w+?KSfb0bcr@okaNG$vLtza2VN924mw|?etSYAV|~Y zG6_Jz>e7EVwgmoK*B&icxnvR3f9^j~yJRo~DKhc2uWdk!WEMu7zjHnT{ImXm?`XDG zq#Il^942BE$ZO(v!NT?Zz>~bF%XHAjSmvOUD-d^kkSX_Su8JT_lSZw)3KCDF{c}9i zN0SlnJum*II`q`C6Z5GjZBY&FV0u4KM=|~j64#jMO$x)TNn$9aa_r#ztxVjv)(HMj zI%&>v;zSgh$^%$a;-mXpbwyl2NNbM|P?IqL`{i=A>r;L;4(gnE* zhV2ic5h4+PQWkb3hnNV)ulX>INFi7cyqg0EIBwo{pw}{K@ZXrOllvw>do70hHaWR-fl+UNNg3Ba7k{lBz4{@p zr`d^Lj;K`c=|s9a%rm-(KbwRL``LwDiV=^Bpl_+aL1hQ9cb zY5-JHvXf(08@Y3wek1&z28hH~q`rD~8Q0m>hNzODA3UL!uZDBE%y#d5n7yX+8_F?m z9@7H6bE|EtaotCvf6gP1OW!ydFBaGtr84MOizW0-kMKN|%M~f!BH_pXJrjc;KYkwk za{4vZ&K*}OQab5`;eLv;;Ns~fke{RA@n8h~59(69a?;38S8`I;YeA+(uy6#<4$AwJ zz-hDsCWHQ(Z}QNk4oH)gQZI0V9l8f)SivQDd9zd*mU=^M8l*(U7(&;2in?bhOhUh# z`=6Gm;Lql<6ILAI#JGirURaCyy+Bp)y0OLzekNt=Fkn{grgC?*KMal0W(sx?H6Ijr zT?O1Z8ZsUzVFgT5r3P1sWO^@r&6?52-aI)#X^pXYeccR2(}mwM z>jn39H&p8X%Ndl9Hh|whz7k*Nf7_SLvHo-CY2hlMa>pv$ei*Yi?eIK_tAU3LJ>naf zmUxCXvc(Sm_NpS41+#D&KcMeoM3i*(!OiT=b0_V(RF8cn>xE|x@c&_6bv!g-fM07V zsTk|CYXl_dF6a!CLHXtNk&c5eU0DL(I@tPoYc3Sco2I>*jg-bNyXDDPXZ0(FRt4A3 zaDEgQpJA^`bh4^2YBxF%@#I$?Nz11hG#L=g-h8Zl0OEQV1P<29|HewXBHC&wX#3nZ zZP(RM9pnyonWSU?Idcz}m@k#ExLX&r0BGdyHS*s}$TO+An)Q`9F@EZksx+Z_*WeQu z?f3;jpMwC&76F$Qmi~Ko69x&9x0QcSFZW%(a!$CR^DODG!MkzN3P_3J<{m)8ZR9^u zbo`m?>W|~lxy{&n%FfR{c3h%|$Uz@{myB&0&FB!wu#MNXnPYZy-8}>o9>x@+W^?vb zY$<#D$L2};qyJ>dnGaoZu^|GU3mirhcTVo|f5R>3DWh$oOZjQj(`o zZgvB_Mi#&s3*i~2*e~~!%l@r;`nbPKccMD6tUpo@{7G5@Dm7&W7e{szF=NQ4=Z1We z1a11;r`(y+os#@a5~=+|kR5yP(ZE{CmW3{;SNUrF2P??rIyhF=lx6Lc_~KWCQR78| zFi?cWmQ$NlcX>D`v2sopRm6&cXT2Q-4vbgQB7BU5>C)@u@-`e=#B)1@-r6aee8-;^ zZg%?^t_(SB@CCK}U%x)U!>`&St+;U;<&kyBQvaQbbPw`4=JCmq&!6gK`PM%xo})_| zQj{gyYm|M#6@u9p?*PiHHA2}^y*mkGa+1%w#+%;J*nG%W)}3nxc{5UPYl@04@K!_8 zj?yOHHzc#x=u}ABfE!Gv!9YD)KO=|n?r-N)+N zj+u5-NTYG1AsNQw$CGPE%zwchlW+DU?l5#G-^be@kGOjjZfD{GI2kW=e*~%EvD;eq zXgqtt_5ZSf4?@HSc3s%=+TL8eAaO517gg_O(O3ojb{*WUteB4RC)8ZAqF~^-swUY5eb5WJvq>+ok%(CvX->;Q9575?{p=7HrjFS`B(f zFTVmoBlW|YP2eYU6R-|E$V}WkxLv1GhbZ0OnKwf2n8Rk(AuYuFFqc`&LHB^x>W_`K z`dR&=WxB3~Uj7xgv~9{tF;83GQ%NzADY3X?9f5E(V4F0qukX;Q`~*F^{KtO1np|3x zv^cVWCVs=wqny^@FhNf~N<1i^_%NjdUhaPk%MQHlx=V7_bJ0zY* zivOm4%N*7a`lCG$M)mr)j!G^wvAHhHe26*2L7TQ@=H=3=bwx5@b-lk-AMsP-1Tht} z*UC|*X*_Jg?P*vdNf1BjCoo|^*17mFcLNd)X{yaIY^x7)S5M_%tMA=~=X@Kt)R)YL z60CIwL&?UpRl2Z&M&G~yO=%zTsYj8;`l}7O57l!f%?Z-eO7d=LL~>1zCp}$>nM*Q# zE~(obl!W_PqX$XnHZ57>EMBRsiS0xBT<7_OtRl=T^=-Y_PN5ppSz~C?`6%ymHDjMy zS%LLz3k|n){q%+8WG+myX_P8r)S3lZ__dGSvf#Lf-{0N&?o0lv2gv~z1xl)IORIR>m20j|N8$EXJHsWzMBIyn zcLP}Wzt}qOXgL0`Z%ZN)gy@~Bfmo`0WnW_Hf}H)rR}%=zB;=e{nPat^C1(+Dg0sF(pgu(UNDpM&6n%MDm~Mhbu}Fr3z4o^YgxAW z!y9^%k2(!ijA@h_JjgXgk7mW%Zdxix>`Ww8jUG#!DnOH!+jZ=NBBml{k=3Ch(qf_9 zyNYvcvpQ&37|{9PQ&ZynzxvG4995bZQsGHq4zCB2w7BfuKX^J6(T|Imnz>{*3*f4~ z`9dA#I$J8t;EYF5{!M9ZM5vS(QWg!?t}A3DlMt}z zRK^TkH7vJY;Qu3f-`n+Ygj0i!doZeK>Pp@(y9;YJ2?nZmdj6@NPAzl19Xz8Gl04$0 z%i%F}7d?Tudq@^8gXlrDo$}Hp?k*nc<-H@*1NH$Vbt5~2^UA2990Bo6IC(h-aLf>{ z)SIRx>+M$IR)JZhmh62Ijk*~h3slJ9UP8E3ju@X0MO)63*P5{Yhv!HEuDMT!?AuoBhMUQ8#SP3n zD8I}M5&laAyR8FmJUyLYcJqx)lWh?}AWIE5j!u7L1RRWv{MgY74$+WAoThp#k+S&G z1^+aYp&ud6fkX@_G3vX0#2$c_+grxgiq1FiVur0`qRYp>zB=t7+}y0>NqQb!{=%Y-9SbbTA2G>7Ncj8eHg~+t2;wFP-;3jzPed$uYvW zt_rrD_ha5aTI?s7wQmSP%x!S8Xx9*Qcqugd+Bvt%NaqRSOKu3k3A?WOxE6u~=n(Zk z4H<8nmMpnFo4AsOQWmO7GYM<2bQbyMF|6rX>(tn@$`|0%95bs|GQ!IQH0~A!r6Q)~ zREMAvcrt>PprRrqPm(eFSTrO!wH-g|?!vZZT zAi)WQ15DA$(K|EfoR}Wo^4N#)*ME4_;N>LYXgE>IBIZtoRI zkfbf*P#=J3(35rrtpj?@8GaASIvIK1!>)`xILAno&7iT46(W4<22)0E5MkE?((%@p zBLuHopH;r$ev=l6NBWQXYKGu$f2yz;7W$7`Nbf<;GD_Y#pk9kCjw`lnPQ)2wpWyfx zNg9nENU06r``L%_ea!IQw?#G7_;(>pPuZd;2mL4YBCbkG-tx}C%`ej?@Tr+d1O2ZW zdT53PnTD~c_WoX|dtQtr>CD_|$>yk$aTbM90ZCQ!R<-S)Zqe45RxZ8vB>7(^%1(;# z$Pyz)0_JCV=I>rj}SAnToV`G615ZIH&vKXzD&Bl9J3$-)MAy&5zQYTrOF-$ z;{}hDnU9XK5;v&eJUW-e7J2)`bz1Wib8jd0-D9(KKT0+~=UM#BmKDKOE~-lesXTmf zDZAr`7+iK=RQ!pqy_#f)D{DL3iPvehco6_w-gQ`5(0Rz`L(_EIU6V>h??ot6YfwlR1$*<>es? zbywpN1RaD|b9*{E`<)McA@Ox__Z2TT@2MgZ@ptEH&x~E2GS!Dp(}Va}mv+)86x-6) zir?ZlaNG^nkqXG*BwY~{TMee#zD&#yIfZu=1Ym?~yoTjEc@!@8vP8j`M8><#E;s+ z=j%#ezfFzr_BA8vWdGhpzFxllD|t70`}XiFO0@W&0Lh0Zmnnw#UBF*bm^a&;R%Z>52pl9d-krW|&TZGWE?}1k^e*y$ z;N%|D*P_1DG@5U(I;Y&%qFP%IPLlNIvv-?LU}v5mDR?8tRjMRE`jJ8itD5pJD24fN zB$M&jzVLa;wPb11qF<<^q5kK9>!&_ZA|hkIc$ax}{4nor#1BQ{81{|~wK#3aW_f|H zvM|lw2+^mL{-(LUmig+7$CX{tr5u5u80-FR)zI&5E0b5dH81g;I8Lv(xiAk4HD2Z= zq~zKL2#t3qPauW%r#62?1WmAz-sc15v3(*hKAa2t1e)cB-^y!a3PAS9z!5@RFWoQh zu=u`D zuPdWC_s5T2Nw5gbyrtLa6!+?)IO<8do>c=e`ThpZRId_Rl4eK56uYP~`qb?eur)c} zYu8)Iu&8c)^R$rMuEvLr;#M7aWGBvDF ztRo2qF?xd4GhYna9L|?zGlpJsR~S@myeM(rMd!}WHEznDhb{EX^80JDTPkryWiRPb zIp6RD0+t;JQxf+h(%r!dO4*sKq(weC&x$-dP=a4c4yd6Qu`JMO!J;PQEfA~Wdd8F< z@NcYvi^8%X8;~y9&3DTPIRCtBcZ8THUY|dpkmD;CH#I)WF&Yr@j{-nbIe;_niY8&} zijBArvg?WwLUb4R!s)Zr%lw?khT?|QV%*p<-JqC4>F;sX6dA9!HT!^7GUJr+sI#OH zJ&aC_b@ZUHU%V$@OSx+>ePDbRe?OBO3G%V&l?b3?9VhjI!CUu_eNM(wX1Ou6g|}Hs zn5W#(klENW}=C=jxP?9GMTHa)Jn(jdOuHb%7v|p+PxIb*{H;ygr|M7pnZ=5D4K*^boEiJd4 z!w-xWW#?w-Z`QsXZP+cim`3s6(d^l)j|6yWAL+Z5MtI&FI>u7(?e^@iT9n2dTn%>G z*94~2)%JStb9iSN5JwW?f1sZ#Y@CNILlbeVYtovaK*9x z`$Nd}^>1EC`a&pptNDT*G+VVdjg|~6sKf=@%^(b5ENL@ZR#|TP6h8x^>3vnK$hF<* z4Xixl{BqAn2d7?n#l1{iq;#09z25t(dt@Lxxbh|-c@WM`o+t2Uq)bBz6&xbmD6ub0 z*S)+%T6>W|R~eZ52YR{4_+tD0HV3J#D<`ErgNw2zH>h9j_WPwr^eTA+G)M8 zg!>hw`+YsiQrEUVBVT9A37f-;ux(Nex(%J)lYvcDw0YTv8}3{%#z@!o7+Q5?jIbf@ z_+t{E>p>H-bHUHTqGnpkiH8A4;YCR)0VAt6;sB~?k~6wPg5 zXM7#=kF|tJtAZg(_d%;&``He7K3h6x#vZDsEid-FHzOnTz#<7>rn9$CHhk%ekKx}= z(xhy6jpi>s1ERjYJUIVhoN~b8dlz!Jytnf=9DlqfJ(of7xvC68b3LVsWzMF+#N)91 zom;S{KD@J+0xAZMH%?dTVXS|@+oeXj>gGK=W|kn}=o>6h8KlzJ7_o!T?8A_!3A+ar z@Q5~Sqnsr3U;XV##?+_muJd_{q31!@2%WzEm&$no*3hlK3NF*G(yAl6iOVcXN6DAF zHIceAW218I_V6l`eq&^-wma_{ZJbo#vly)N=hxDQFZi5$klxo#Rx}2=VoD>!B(5^; zsuB|ei_KF$UF+Sy;~!q#llU)wfch=YrKVm$@9R%e8#Hc9tk-1#F4_?9aQ6PFI-lHO z=nA)xpJj#UWcp5D?~G80>YHtg=3rHLXrNyV4YvOp498-v}kif+m*FS2jhom^?+DuVPw+(ivR6?q!{P!672-LCw4%ax_(K zvt>?2MCQt$3j)SUk#yGd`AQGQ4ho&$N9+_R0q?6hAE2|HJb_-!-+FQS7w+QZ16ssx&%n?#uo4>^TvOzMUeK zB*k1~Zx|(<(S=fS^HXL!stwy+;=%-To6oCqH}?UYNja!(2r`H&=W`f7iI2`Q^4g2F zNq#l3$T4{66zj|-5#__FYhRgzPr6IRsYw-6*xSu?QhBG1*FnZ3f{9`!Y}2ThV!ZNz zH$kC3D^xD5*nP9>jZ)FpTjg-;Uf6-7?k~@JY_Id_=l(NPmD2P20l!U~Z-#B;thtKl zy%LUa#Q?cLu8mW%j?NS<4jci2n=$YrUym*w%^serxjyPzl>0W%s(-@Bqh0KW+a73{ zVaYJpuuNdQN`b!_n;qt_BM=cZ22BEQB{g;m-YFjN4_33+*SuaRPA1b}L-ItpJTW_A zmo=eeJb#DjN#5pIVPoW1Z~Po=WV$ncLM2gS52^GNkKw#wze*lS{~@IYcQXeqWYXoL z@fVLQNZcst(fP49n^DjS88vga50+m3h1#bA=6?-vDCACK8?gFRRXYkW*2xS8Hr3`37c)mF~53`MgIS`B6r%-Ljj08!hL zxqaAblaY%`uYFlZA(Q{XU5 z+0=b+<*o5d=V`JcWPK|@T;67mJu4ybPLnwg?-@5A_ZTvC33UQe~RbmhZg4++!a2VFEiP@@kWj6rf7sYAI7 zWhWZgP;svdqF{9`{D=`^9LEp)&R6alG?mV{sV~0DRkc5R_9l5{WENZ)@8Or%iTKUy zm)AwLkhzW*HkwX`-}^53Spri6b!4q}nPxf_RrrNhMrfubT|v1wSXg=2;Lcj{&qHM2wSv*xN>k=mp5wlcx^(7El1>aa>J;yp7yW(8W!)4kv2 znpaKN%BM_e3Ov!6DdH$E}J1dKJ%4{s^?Ne081rf%t&9 zbho(U{7zz^8P{!&TkAhrCXCcg@pyAy{X$KR>nZGz{OOs0#b-(Tw;?+J*g4ae-2ncy zpGASiY3w4u(HuBz{9EIzi*1L1TN6TkKhby|xpT*@7$LP9A1hyrtzz%kEDw>N1G#MCZnf^dhe!{j3$VML=C|>cg)VBNg>~4ZUth_$85t>6SUdGK z@9m=hLUmMYmjkUcI^3!u5Zhsg2-K*(2AeTdMX@D9%L=aB!XA}Gy+<3s3Zm&k)ra0&?-{KP|i=K5`@kcVASZUgaN-u?Yjy~yV zx&C~xQkAr8C-uaFviusfW%?gp!A*fS=uhP0&SwU@Cb{rQS{#q~{8aL#O<3!>{fy#d z>}&%*L^e^1uH;4l`F^^|;Kc{k;oQv~DjMbNHL8p+wOWA9{i)2|4c7wiuWP0YW5Nad zVt(~?`}3#XdBv*L|KY8w)0a&XKVpndF>_DV;SLJ&0{$+5i}Czi8{{24xp=%8U-|OJ zY{}$Se9}WG-JJQk&ioAO`>S`}!#DoUa}ivo$Na^ueBP?D=}9Mt7|2eP8UOMB~)L_DU-pJ&;j0XzUgMiS0l zb`DrK{3t-Q=N2CNAM8JM*20U}j$+MtL-mt*PKy(G?9cX0t+hzL%Nd8gvaBhHP${fu zIoRux%>S0p){8qzal+xmUy=flE?+)-&`tKM{>=K$M@X(&`X63o$*7!EGfSm)KGTS4 zki=B6&+D$03*vyyql68X(3o!vE)7?AInP|1Xxe1jTByxt{IzMM%tVI7r%~)+8aYj; zh8oa%ztuCHSi5A>up@8(N~i$WuezHTGRMMSKeNg>w#O+L1-CNw`v@*WUsSm{5gUBF z(dDYdVEx?HsF8VRLQhw%QPcO6qLH~*xmcvIvXtSsFD=<~f4^ zVxhz}p!D3Y43RD03SKJIHqVRQ&CuSr?nS)=Mg*gB?iAo)TCjs_KHH_`y zX!EkV5FQulTP)*L)sVwtWcAZ|f57p(pAe&g!I;gTBMSAxkD4^D#3`idg|5CS3dk#{ z4IY2Qvm?WM@PBU(V$zO)-b77XWIv7xOE2VV^_?ei07N0*xA81R^&szi`7gMy2FE*Z z%i@Rx<8&=`ve0lB74b!@?MK{nYEe#a__H5wN*WJJJqG8We`lJwF+NI9uYB258IVkl zE^M%}(?Z$&xx`RvTSg>f_nD|GHFYJW*Usm-z3O*n4Blhl`-1(p8N=V~Uxe;Iz3nh; z`*7Q}c-g}u`WKAq-2KG+>%K5f(KCJ!n^(^9iQw$1&i$MoyY9;fN@{R8jpe`ROj$r4 zK@qxj@xeY(dCwcex$y+X^Zq%AsrfpA7F3f*Efv%^kIY6#!K?kU*tWx2 zzK8bzm0h4-WLkS|DL3K(ynI%#z@DvM0)S@F@7vyM{x!--pd)b3hrj{H>Ja zcr^XaV+y(|cm=F$Df?Di|8h~$?~oEFnj<3xln5Gj(P7-5Fo#JGN2-_BT{V*jI4%Rt z?6B74;wtgBZy7_ZjwSQl|NYI|R^>BsoPDB@$+8Y57PU2!zt%Wwe-vX6hVS1%6PEj4 zJ|}6UL+ZH6wWGDazjl+|M2Yao)QuOqD*?mbj7ng89Tezp;omsB^tDX>u_^f@)%LsJv-DBYZ zf(*MR)`bm7B)8TqhDXIMS?&ThElMT54X%8Iema$ zHlg`fTewl~)9VH6&Opy0v-9fxw|%_cDzy|r+z!c7yc{dzc|VfN4aqVM0b=+=_1KIk zgl?DaU}XVUQ}7}Z*YAH+9n$e@=L(B#+Sgw_AmlOB3stmUN_W@FBGfkbzqy$%G)a@O z&N7B8So_FFB%@lj?KI$*O_39Q>yU%B#gc+!42BbOH(Ghv1SXz3*cjdx?N2rhWeEEW zgn1h7B!gQo6Y%YICSy;c4(m+2y0f>P^|vMos68sy9bb9hL0Ba2G&c zd7o$X;u<3*k5Wra8I5WCo(i*VHlkhKNf1n-xqkXsNYC^6Vbuz~4I@n!LVyu!S*WES z`<>8ua`KQnH~rm~ z0!JNMNfcShr90ki$3o7XO)IMZzEWc?VY<)k3>^6VKRo&_?6$~c$EO@cBX?RdaE@bb zLis2DBG=J43-?@0TL0CFUcJcguU+$A=v>0J5hQXiGM%JZ{5pxgZ+Uh3mAg-ta0F^o zk)?}-;uwp{)*z4ypqOD277N8V?L$abmCvz@4syp`Z64eAJ^f#)ml2`V`QJPeI433h zFbY}*%mnS`g2PMS?B9B%uW!U;d+wz(qMp*En#~hd$13So+HR+O3*-6qAzjM5qV)-}jnqUC z8Nlpa4ad7sFio6kR*el*B#!4Xclp&Ilf;B~a5q$3%BF5TM`lz_UqoM&NV(yCxdWj- z2YBW=8Cpsj^gzf$OsGH72i1{n}z@$fgD}8~7z2gHh@hr$3Rhn;z*q3lJV5qm1QdoKp&a=goAuTfLtA@P0$e zqWj*H=3TYoW?2bTVm*^}NVRXaqf8d>r;MZUYyGogKfMJ4^`UU%l1251)Inw|Jqa7a zE38+S|0}YLK<=A7OvmZ~&6ly)6W4G+3DVHaqFf6AB{$Kve^xwl;yS5F%LT~MEbfi~ z!wEOIXfCzPwGOCuE0R!HDi3hq(O3ze2i%xB5M}!2zywv6F4)xtdXN*>`5WY$k}gvKQ>KP1IAp!#YqetEKFZ?z-Lhn7)*ZgqCGZO^wPdI9 z<2Lek$?+h(A>w`yy(wBnC9g9qd}$K^ZLxYD!83CB6Cr&0%J(vv&IzQoBkG`hhW4e` zAD0+S8W-mT0Rf2j-m79h6VC#Ea;6ft*cY9Lsw)p>eiLrD=ANWzHhR2FyY|~G5TB>% zQ!ok=KS@<}k{sH~+OXGP<^8fmtS=30WRlVok2(lzs$KosdkM(RK6|DOpIwQcUp*l zjE9a(>9DUtM2YT-=fKV!+d5(!_?M2 ziZ`JoBv9NoM>pr^I|u=PIRVkfquw6(iyiTb!@^sIUXYV|{nKae193SV{1O7H?w6&T z2tpkKjS~DiNrAnJ4hO0!F`W~VyGQP!+b_d(OFL}fR6KjqA2v-+_gG?AJw z0~1GhJD=$X-ENZIKZh1#d?~YOJ5y{44hNs~;YEVOvG?D$6w@R4Kb=2*lBZg%eP&m^ zHO$MihZV-s($xy~kZ1DCSEJ4kcoHuDjpg&^{rlcwktd$-RAjt;ST07(&cfM%H6g<> zL6;$2Y!KJ2a`i)tMKh8OAb2d1hUYs^b&zsaSly_h?q>&KjEq>GEcJBW<;j;lZM!4+ z;sBj7${o%~z+P1Ak1kGZX(5vV6Eeu(I8=ViKSiPuho1XE;nj7!?MA#!ceDMCz(r?E zR7lj3lwn~pLyx+yg6hz~)^vz(zRs60(?lSj{1sol4NT~s<&=;UGsq5??0U%s8hcG5+pHjjZw=&%g}=nFuqP`LIRY}HGiRi zoV>oQOgV~;iw96x>!6&7vkts@miACbH8<~*7G~-y7c*4~Cb4oE7&fiHN|KT`^a{E> za)0r<(3rZ}M6j`CGh~!T^JySgiwdOQ)IrV;bo!p^sFLycIQtlSIqE;KNvv&2Np$qB zac_=u)Zb8kO~pcc!?&VOi<5f$30dXg(;tHwk~Wi(CR~6TX+UJ`rls*d1mjj^h6P-1d$d6TyF?!?jm|b$xu6 zbvd&4+&Pb?auw46+`*aAEyJJ1hQI>`ZN8Y14f3b!Q{62Jcg7EOcg7Pu&P8etqq@xPE#Q?~kO*PfLFZSm48DPwLnwj01P~Q$avlicYxCT-)I4+{1_0(c%bJH) zDO$MokJ95|iZqH@6JfQR^M1zR*co|57VkExC^wfz70MGUa0uGfPX` zD(BipAg*0~Gt(gliXXIyilUzRjXX!opj^QVooXh-{JOFWE)O_=vX6qI31vN@!Ljze zFEaeeC*>Mm7$;3zJoZak>w*Gk{X{yN!_!ZCFv%J%_A7rr--z=~JQTe2qhcy=`W5Z@>Ra$% zVs2bMLW<%C=}T1`m#kmPn&P9eyU)aoMG2^%P-w&KuBVlOBQFU&Qhn%&( zf^0I)eejQaU zP`f6RkyHtrL-kT&^Qj?^Ag*N!D(`HIpMm$2HCU%FU?=HOQ}y23Mx3k}x`&3x;k(V% z8;2fc&+cU|FeZ1VQw|oiKfkPL7;D;ZWyoa(;i2r(G%bV-UFy8FMtotDf80Y|i+-)p zfZ-@CF>-~aq@3K}7HG7HW-c=^?;5eMONh4%o4?;Op?;_(9N_D`XMx=XcS7&&z;k$< zu9`=j?6W`=iRGwuijj5bGavrdfOp|2Vhtt_)^_UU>2!IokwLeL1VnARM@$5}vq>WJ zyRu#qw*h7EMV^<|BEOl{adAKP_x>)R@f=yIg5tiK8ZfUV&x60Q)ZC#KNDs-JyLH%X z-^CwRwD*~yGYT)EC~W_*fX@u0KK{MgZ~PB?+_V?*%P}3$vbImPy{zfpJwZSN%dwZ) zDm5EGiT`nzC^m(Z-$3fU@xRsV+k}`|)d5N6Z!=>gaU3@LNTTgvdfI1o9(&uNc4>4v zmUND%_9u40;AD{&Q7=KLW*`u@(WQb6^AkPh44$9>TG`M#ZVq1P=#t94r)@}{d(2tP zD&!@OYx}DMfve&@AjiY|zc+mT3%V-Pl|nfjs$HWcfg=mZm8KFTV6}0&oko$=$XZdk zYVD5!5NE3`h-xiFVnExB``cc=$s-1TUUdR-_E)pO^Hq9W#Isq(T}*f+Jm0HgZnWK_ zEAJlmSm;8J^-o@xz9zzDegC1a#6n=Cw)MQ)GxV(wtR{>CanJopq5nBpJjGR&9W5yU zym>gBspjI!UjKNv>s45D^Pz~lr1z3b$5JfCsl;f}Y!~Y-0gSVbnPX36;%brT)6$oz zR95{dROHvUR|#XmU(*|Q=@Xk5*GKcKZ|f@>0qyo@$0GGNT(7Mq^(rt6UB&;3flO~f z*7{H8MTrGK&La?6ni?SwMhFTQgI*J{Fmd5FN7!svd{&jRT=3msFqGi8n#s=+d3=1Z z-FklfYuZXd1L)qOHRwTpW9K~Nw5{FjVZPvvNNyHYs^Oi?$gVRlQA%rD;h{@qfT?Kf ze%WxzP0pY^u*p5xZen`yN=`6}VwNVSAc{e@@842(0y(8fd27$_q!h+!N&ol%;Wc4k zlkQpjRVHTy+z2?pP5-TAv2HU)a?~T(CjMPqzj)???sEbl$ny!4<_zWwh?=kT)u1K! z)-bY-F{{4Xx!C+Bs@h{8J|!V{?Tah{7zPvSTC&hSks!_ zw_jeA9|S5Z837oT=^a0JyQeXV!&Koxi^V>=@S+F-NZ9=a$sj&-;j4pk{T=TXLT{0X zs`^ht%$fHyOqGjtf<5?~FvHVr=!FHEdfO`=doQ_Yx^})x2rw?LlA#!_wQpLqAta8n zktqN5JR7hdI+2CZ)vCFD48=a@0uQp|T-S}N`}dmeTGVBTNe#)$`Kfn?tUE{hD0D4^ z;RJZJfgVh9 zI=fc%n?MZ*ao)@USovJ?goe>1fS!XAO~Q^7GLVwt3GtNQ9IhLMCoB>XW*eg*xWKnT z_kX(6c5rims_u_1+V&e}p@qt%X;D|zL~17yCC+-)q`&J6BASA}oL7x}4b znE)%=-LAcqu>)`2_PT||91AajQ5mr&xJ_quSeVyKZpDK{{7T;YxUDe2b}HEEg}ImJ=-|+!m9jDy|zbsf6%k`jV8QOo)${ z9sR<3jkWK?i0vIF-10Y1if0M0+0%3TpL@5y-55s%S2A6;$PC$^0Ja+}qLNEZ%GU$P zm%*BXuxsP(Uc-n1a&p+SWRKj%)j5s8O*(BuS%v{Ru<|XzPTH~QEV7GG)q3YYJ&FxT zK-v#-g;pK-t||p@xn%(Ii_qZXQn@z?p@&ZgHPigYt{zmeW>ArnyRNXWt*b0c;+YRHhuGEe4B1rZR+gWW0TdG)0q2n$Og303bhq#66mi53(TiBftNV^ zD%H0o|Gtu}kf8aql6_b{ZK@IBF_h`GN?TDw&E&(_1>Mx<|IA40xpZ0oGdRK3^A((^ zthUwoavA2w^9%s#Xclj&G%UenZf7kBs>IWOV>T11vg>g0Jg@WNTQ~R1^^hxosJ-QI z+YMK*sE1lEvzt(@8JF5OwR>o~Jxd;@xbE>51})`*Tezqe#t{SG4IpYf678Hv8j20V znqy^dKDKO z?r`7vbc*4OrwL^1q4HW27EJiPbzT!|FY7x1HJBIgF8-RO9A+^9J!b!Ep9S-ivW~gyQ!%S;)Cv*D$9#jmlqz7{4F1M+X8XV$kUlj-QmzeUHPApIB zoORBI_l40snhZFcmvq%PdCgeO!oS-oy!ligMaeP|Cnea`&3T_b&L~skG2LhP(qAZC zU!6I!2aoMIDHb3t9tXeg9&BcZXD+UkksYmiTRA!%^c)~uN21JzQ$N;oXjlV5tftuy z>j?D*lG&lX+79ewkqrF?77@h@|Ke6p}K+!j=#jpBxFKq;N4q18r(LvPqZT#j5ft{;6(By26 zT*sbD+l?$z-0bzEa-0uw8|mT$Xak4Lg5G=ryELDILaetAJs(s1$RCe+ChX^b?ky1- z>(=NR+~|(W*V@)nrs)>z0Bp83$ww6_pFPWwz4h|11#Ufw;^FP3dzRKuA@$bHpI^T) z$=(&+5(?rhJ9y4OQ4|8)YI$rjeeb+Xu88b&26DGGIw%(GMQ964c;VeIOQ4*@wHMmZfEw9TBf0%_(6M! z6tC4TJd-n1&T?)xL%{8MI|dfEV^KYfB+(IT@BRGk-t@r?0883wMLXf2w+gC(lMZv( z(yerSG`3;c(|YQg8w~+*)GwMed{ypmmOV!s@;)Dav$Hv>B^l3S;j^TC-~j3c2Sm&_Dd7xdGM>DL8GiDCRQcbG*wJA4O}m zm64D<7WUU)L7BCGmm5T_6N=M(K3s@3-i`g7R}7ds zriJ%l++N34Q!PdDaY7;oFh1e=%NV_#!Stl8{g=9VB6mWd3EC?mRmg;Xf$3a~R-sJy zfcv`|a~N9fgJ*;4+Wg;@#^Izm$=5$#Fgtq=rD-vcm-vXx`(nwf&9|SDq**b}Etx;k zq7PSjg+IKqLC!is!+hJCTfLlsJ^HU)7%!pLs! z1TzLOi?*&8)vTvqx90D1iprBV{Q~xiuBLVkAnHJ!l%vVp& z0fnH5l4a{>Cyw31bM1en3iclgEo6uuGQq4X(qhDNwB%_DO;2xmEJRYw;#cz~<%gU8 zZN`N%b}wxQw9cxZ`$r>WDrQ=@tOk51{Ntag&us|^8;PZspg8nS-ERy>6dG1M;M#JN zA+RB;1d2(^<;I_bot-|2(y41MgHYZBI!hJ))HZ@QghQUAWm4qc6VY~EUVN|?Vjr4} zmHoFSC5B@+`*p{PMPU8erSQDH6i{(#DaguSSFJ9=1o5ssw$IsLBf%L-E4~9mE%}6M z3r5ss2wTcfp5z7w$Z9UvS7q#SOKZTKti_Zmg%IZRu$*Xo3VRrDn-~s=;`->!o<@d2^3V9zC1f zxB1%Pk&rx+BIG5Z4zedY%>V8w>P82uo~PtcQJ>T_h7r>jT3b@ImIwGuD>t`m)dRM$ ziUwt{3l3xOjQ4?2N4<-DV=<&c{n~C`@>x38P9a~WL=D|v&=Q_5TO&eg2u#);pE4w9 zN-?N@9qDalkv(yTdBa>n61Vk=8~Ba$G^$pFvBgjR_V2~y!@3BMg7)(T0qc}Fxr4fp zfu;b5*Js*~u~?(q^O{8Vs#H|O*W_st(wl?pzZ>DN-#=UQK_Zj6X04ILt}g!uD-~pK zUR1&VMMZc2TDn(hTu!G90E8>@xpoCFbInxECkc@eV1=-dnKy%NZ@%Q9<3}6ulVchs z(`GOOpvA;q*%oVw*pckr&R&a)P7~<{GBFRGoG~+1h6uX!wwM0(M)NXFI7QR3^zE5$ zi>)^4qPmv%S_U!*Pl!F0XYLQcQXPu;3Et5BoqXHNBrsxKW1xQcB5od1y57L>>fY_q ztn)8W#JT%Yq43h*I)2d2JwUjas3Wy`Ox<_qG!vPG115qWATv{U9}+9Qn)KImRyg${ z`Xq&g1^p6}{0~oR+HGa<;+Y9iQ)p?1Tf6=H{7>s;Q|D^2p@1(13%ZEbubu85iL)k4 zKxK*a3%^~Xn_26n4Zmh$hFp$hA|PM}Gu{w?Tw-c&zx#uHvXv-3MvHHj(7j-<+qopH+i8a5U)9B9c(_?xEa5s z-v+bjDhCh|yd3wvlwtpu(p-WToYdx+U+yeLN98?k5U5~ZOmC@(??nX%e#q+|oI9`V zlAYiprzFTN9do$A zlnGyGHeh9QFsjL@Dg}>duC4fL1_&|Krk4+Cgw`GAv57ulEciPy!OCSBH0eOThXBDBUVObxC{CXyXMemMPs2FZwdL7b^%v-IkSOe}a z7MCA#TY$tv-y)u92P>9;NI_#z4ewT6dJW1oAvC}J)HEsEDIqAeQU&y^7cl&RHx#1N z@0#}n6#{WPex>~7;|DG1ncMa0&r+!>X3#k2I7?BTuC-#x{9G4yOZv8E>%1-qqLC0# z>fa9bm6^|sz89K?K?$8CDv}@&dTxuC8icf2`Rnhs#Uq4V{e6thjc0Y{U3nye{@SL( zYCuotK+pKT8yyl?qrHoLtW@8Zj#nA3K^<-Sm1mRlt$Y!^?)U@@35u<=QjIe1e>(U` zLGd8S0+bo1eqP5FG*ndcr|df?gU%12R*n#$_l0ZZ2dh6*mw$SZq3(sWt$ZGUv)GEX zXwy4Lr7%|*qT7SCqA8R%bIt#}{oFrmMM-onSz%Wclj#ECA!}KaRT~VMiuqxxA#feQ zhQMU8_bsuiai-;u&Mls7Z;TMu2?dBuJW{l0kYiUOC3{?^G>H}bI7jO2^-j=b={^@=f(HmzSY(oD@y3cYchktJR zh}22YxN&)8wZCpOWdMp@7n3j`T=TK|XlI4a<=jL4T8@maB)p0Kc*%moU7 z6ouxs$N&dW=Y6CDGq|5^W%P4D`w3sWDKYpppA>_vu-E87%Mh_Yg~}UQ&9s$qNqlK@ zU6}SmLZU|zD@M!*Pjp7DR=)?qD&=xSY>eVp?%@q>vxry_vhE8Xt8sQo8hf*=tne#giv8}y=_ejTe{8+f}^OMiJp6)8N{^K)G zUMDC!_!roNwRoB_p|2}%^iskA2^}vJm$3s~TFM#H)r{ttzUr{35=^&rRqHK>ANfw2WQA#*{UYN6gJEWpSG0B$TbbstZW9aV-QNjyPw+;NrA zOiAAJy|dhQ_Sr2Wl)b2?LBQ`O5hPf3ks?&-1rnv87KLl+r$rhfd@=;qXSm0&u2ZA7KU&M`w1tuu^khdA&PVP4=>K&HIi*rjm|0=W73O$ z{`OpGaU2&~enRF|!kf~%)5ezwb*P;4;~ga%5C^e+8anauC`QRne_0@Ef{vK#-Gp8~Tv zz=>X+&Jqfxv1YM^8|YOaG>u9PID9WNL^dl;k}@;F4<*#?GjOZ~TWnwG@F}e8a>pkb zyLb|Y*`Dd;h_rtsAH{9evS96VwCpIjd7184nzmcfzsz}@)EBt0>P(XI;O2ly@w-B? z4#zt2S6@H98~bs598vcb1k}iG?Q#C@@K3Knw?p&bYghVqFPbJT*Eg;IueCV6!wY*Y zGQjhpePLi=`mWrEOGl!>dze1hqYx#U)21K-y#u}>%%y%5OK!*aranwZSJZ0R!Gep& z)9fW5l$3Lj5dta9P=2&>VP!807}e%e+}yG8uV~wr;4Z*@I-C703yPivT^Q!#Ppbdz zipr;aoWq^nNAE194ejMn^D6Jj-GUQs0A{I%d#X8Lk_D6{gG+%@+!RVg-XsWCHGub| zqj=%CZGk37Cbo=BY8fxO{DQ&f+*IEyX>MtMx+9Pj<5Oil`}P8PSMXNri(oTODCBcl zuG-$LwnEfsb7o0MK;~C0V2Gl%`#k7jrHe^|4%fDtn+aoP{BHuAnqAW)>5B)beL4@S zy*ap?OKA-qhod)2$h3}3dKu1;%$L`$^oyaTWB&LzuRmn?`_w1k3Q$@h$9Qjw8GRdC zM7t`k2)fFl2MJA9WUK#s+stFMJ>xXukzJP!%bqd>EX#Q)_YC&yPL@+_cUwOka6 zOqWT}7Eno^S`KK|ju(oz=9n}&e^I)$cgwQ4KV$~4xs@fr!srsb#ET?=7GH3@`>@pe ze$-UjoTvNy1?8>GQ*IaPuok8{m?3cHiByxW#laT6?VC_3ybm+n57HSoA5d~j1-na= zQex~nq{1xFS!D-c2#QZ}u`H-z_$_AukURw!@j%aF0Gd|m?GjW2)6AeNeIV$rFue5t z*n97wCjNil9}6l~0a2QO^p5mSl-_%-l_mJ^?>ZK{GR^JpMMdY*IF# zV@F$~zi;~I*Thh6$XH6SG+~@MKh3ySdRNxmXj$D;?~|u zw@QIiNV>&H+(Y3Bx1*BhLo-hYf3~W;HmQ-$oDO> z>kmzpIQ?~DoUYN&Shs`#y=eZPUO7YNwB=}KZBLIY0X_G;;Eu4z&$=Yb^fgP)c@#TpXpj+LG`0b2T}`NC9~oJ|)Iu{*g_>*< z9hu84skJ$6p&4p4sNnCC!?O$@t3UiW;`<$3;4B~4;q9hjv3}y~@i49OS}DH1!V4B+ zVP$;c*02@0snsZb_PZ!9NiaDv1*}`3NlP7CxCMhYN=&Nj*_8OjEQl!j?>1I^9Th$T zNRkn+-=2WV0u)C5BZ;4^*%pH8ohQbK%PC`wCFC))KIuwM)SQOPd6`X7P0d9HuzH@< z4uwjCe6|P8$5FC*fXRf0;aRbLnb?50p?E0d0y8V8bl(Iozbzn?R;wE@bWXskQ*l{X z|1Ncq$qRTKrFUu%4ohp3byY)xcL+7GkqvT4r*ZzP1`R3>$b^Pj2%cH~%HDo~K)9;u zmdAl~aO?QZ)9{Fi^PWdXAb;(0J>A_L(z^PsO{;Hj4|SuplH&JrR2V|19% zBH*l?ezK+`opmk2uu>6{Lf!AYv&Qc3Z`xQ?Ea$&Zc0pSGnXk_nwYMm?$ymJ6GXG8P z`sV&(lT3>O-__89Z2B%!@CUJG)16(nqTw8r`m4_+QZf7QK1^r({`mAsPAw%8>Xm%+ zM&L_G&K;shXN$D(y&VGmU!oUQUq}g`J9JvIo71tGwj!Qgs(kbhY8fl=@5UaG55P)_ zR=08}bs7afcK;}kEj9@tGu#$hxH}vBqKm4!E{%fgL3osB-1oZj0SQcJTvhiX_Vcz& zG_Rna`AA}Po6ncDZh9>h_n{dY4wJ%uRT7Vh;U?u(IRBgl#^5K6f%8K;OFlhD-NISa zmuevF%iBP*wZuSL{@12p)nJ6OgS;I0$Xl_ObCPUcU2{bz^YDFHtvFW$>SsTN#BZ4q zkx}s%$);80q=SM?N&S1)T247ao(pnsilqxAR9;IpZH6W@a?j~-_AZ$reF$n%KMd76m-q6AEQsc_`Xvr(JoPFQy3N7BNbu@{T zU2SoXlaJ)3X>WMg`B179>S5&?r1!aFFL{*Y<~`Lu*r^;lFEPLscaNiCC2P-JHiEYxIvNaB#ar;m7L2CI1 z`*ufWgZa1V74(an_)b&hiy7+0l*+3alGU;X1L~Y~h^?%wf>SbzjhxYqIW*?>nWZk1 zE1s*JYj!4l$58_+eQX>yk5n|q4B72?nLD-Y3BA8>VDHNK`WQZrl5XhTQ;fP-I}e}Q>2E)=YYM^EZ+ya6F5I+3>a zFU;yn%T)Hc^%dJyi)$y4;La!Q)WH-q&5hN^0pCnjpD*l{&rC^=F>WJHdl5LbEJ}AL zZG^Q{?OnmH)D!-P36Ndm`A{KG&l(L{iMEd_YqJfX5^Fnp*!=oTQyL+;u_iD}FqeTD zG#<=}F#E@LDGVw7<@MoGSsKOjp^brZL;pk5ooSaTtM>sfjTXz|o0cmm>}d+vbR<1x@KsWYyM9xHSkeK#t;myWnVCsU z+e@BCcW+!>2F>U-q8ooZ@n+B7V{`XZF}tI-IG-kapO@qx78RF9YMn7RKmDA{SEsa+ zObiss4A261=WlH#%cDrF^5N7TI~DL_lFJ&Ey4xK4fS*eP|>NH?;Wg% zyg!^#c#qu=IcqX1Z5p+funkTENX)Pg{pIuwMKnyUZAsOJ^&Yt`zaDI#31V8HQjT}D zx9QBL@1wq(B~{~^HTQ4nQ*3@6&Hdu?&mARP`Iagg^NhM*vt8f$)|5e`=qC}wN}?v| z97)3ABA1(O8MfS-2D^4U$TEUbBuL&D?Rlrf5vz}?~!nuSFEvxsKI8FDH;6=25;!N<+q0ifa z97e<}3*gF?Kvx?CVwl{Yt8_qT5SJZ~s2$H!y1t0~B!2brF1AJ4JD^>QGBa}}Om3rJ zhTLOClj&ZGhuxL719jeHW8sn`wN$0Qpkg(tL9tlakY3^u7dLxY<9xy4oJ`-&Q|JG^ zT;#u!k^JY+&8I4KZ9&b_UTe{lNmC?YpmCE@qO3AK*aji>IQ%MsW0SNy=q%?%T}-wt~`T( zSmwK9ZMKr!c<93TW!S8*K+pdA`fa9>379=)mg<@8HUB}UyB?yl&PJU9pOIU{jdC|* z8Xux-I??U5T5&duRpOr4NSe4OFcN4GC|%wW81v_NJaP#sb0L3v7t8{x!EWMYddIM1 z=x}@cJP1zaA`}+=epzO0skc72;*_!kHA z81Bpu&pIBLYtXRgzMiRb)hjQYdRz1|dv#!hT?eBw^nSb_!s@DCYkjXQMIp6W&oU&w z?nm~M3FgoDFA~Dt5&lv>5Sn2s8x7qT5jNjBnqX_`u2fGH9?i~~OEoDn^%X^nY6lp+ zQCycgX$CQ@H`RM~ir}%>b-?SF8B~ddgSvW=-?6Xv4$$Ed!b^XN+SV_-?7y!ga6)w} zNuopVPsRYT)L2X^msgk-X!d~DN2HVS3v=_@fMckHV`<0?>10Z?qp#U` zVnYZoRI`&r&XZOuwOH{i*?*R?kwj{`jxmUO`_i;oq*sOVmQ=xN_;*ggmgxZ^>hT8C z%lOjAR?B@0e5RzYG+T6(BI6YFTcze7fLq088fpMLU%$>g*g5g?4OWcO?&D#%P;upM4hf zFI;tbuN@j&pV#rEN_e!|>`hiZe3UDe`Hg84cL-}%0SidJ3%7YIzw>i#yE63E3MLi| zaw>f4KQkQ$fi{~41V}yG>i3NN*50r_P?;M=&`lAiyUPW>EM) zmbtyZ4llaMaUc<~_?bATEG`Wx*(5<$yJhA0o~>lAr|?G?>USe}YVB+)r4I|j*2^~` zk3NVmBu}l|_Wlt1b2!;;`>Sy+=X2}mM9MpVr4wdj#`qe810JwyeVvnQVHvH5=DUv) z?WLk7rBlI2?MW4zk-=_9+tx1g0*E~w&8-KFEZk`!V!Tw2)p zZf60^pY`*EMD7f(TR{MRv^CMZTWIVDP}C@o!yjB6&&gbLS_0tQ2rwMG!7Ot4m#7g6 zXPH0*Vn7k_vXZKUTT3Fc!-yZfci6K(06_%0acNy+iCW zSNP(~B&yUzHJg^_U#_#iCh{VYLK+qYSq3$Qt~3o;SIXeu<=0NBGeg?v2_gBvz27yJ z3GpQ1BMUxQRY|to>Y;hpxEZ`1|AT?v)ZayV2qMm z>39zMVJgJ@2;yzGyukY@00yBJOZD)Ksnx?v&-%+N=)u(zJ5V$)A9@v$4h0A@;qqXXnM4Va5PKHCo)Njh zK~w_d7q3(vbT>`;Am@|uBN_tPAVsFI`jZHkPhc6*n z^kp#N5p&3$i>|vYPmUqLxW-PQq40AsV2moghqx3&{4_MUmLUv&!^2-R1R7!y8GsW~R<^D8@zG-=P#Ek19?dYdozI6&= z$y|^3;<-|@m5(fOLp7AqsQW2GT)#+@8}wU0g?w7s>Cn{oGcQ*!l|zxP^W=3uha_MY zwH3KK@;-kxXw`OW4&P2nUa5=0v2hytP&aN>E#!jQWj+>b+_7)bPD@X;Q;!eaI{++l zrxe5OyRb+Q@b2vkkv_m!yVc?A58GVOfzZQsRC(R*nSD9fyZY>9-ZT5pG$j5837jKE zsaX>y>23AaGmgKTD=fJjJ&fMC+2Ylm^+rrCMb>pg=RR#zR-CcoekA44k91@$E7MhT zJxII$qo4XiOJy9YC6vu{UuCc`e)Cw%ItZP=x2rWJ8wbZU1Be6cCR_#$T1RH=q7^MK zAc_CD0JrU9;TM027LI!3FM2j_MrJ@GX4hNgwln+Lhh#ybo(?+)-5+3PUgNT~c+hNXj@6Ht zIOd0HhFl`Wvt7X!A7u3s53!!HkHob!H^OR^WYGVtV!iRF7|35DkBj>AJshZA0TGA> z371|)EYTCdd;5>P1)ot8#VEul%ZZZH_~`aGXz-qe6k_C##w?2xOk008dYA1&mp-o~ zr?{~k>;8r^fH$9u`E#w$(ix+gOP&Id7Y)KA5C2q&$8+2btQO;M%$Q9ikjH8dxE3A{ z2rib@FF(}vywk|ahsaRc;u*846RrC7W?x_}KkJubg&{+y0;jio7}&TrB%(uUZ86PK z&hcxMBmI<|2e~8N!4cu2GsF{kid#K8!{INdnQ+XC_9qwslxA~*X4*nKuu2phxV*mg zD@6CBj}mxc=iX}~(E=hOw*RMI-@^$dmr+7j5B>fU@hJYMeV3NScnC!#ot^WQng_uEcwr!GGJ$8LTW5!-*C>VMwL z|Gt6$-b4PoTm0AK>NPL||5wEL|F54&AJ-CqoM8OvfJu?R zxCoO!Jv%)(eJk`7693P4P4vB&dvE&Kh;Adl{JY`IAZby1kJgP za}1l+fO{Gy)X@f@#^lx`>89vmx z$w`lO%cFPfa(FC0?;91dGdfN1>G8bveKICK#$j&(KxQfBaVLL?{3A|pBR4NPv;GnR zwz>8n*;a@k+l~i>@NhBQ>Ji~m(H@RHxTw3+a&0epd9m0EeYlyF*V0B(_cK0?gQ5H` znzpuIQAEbY!^i#uw=PV7_fU8#tr|_cyO}!FY@vW)mbSPB7AkzQm?%QCeG?q>7i0?k(to9@PSP| zZIb133PE=NQE{E|A|JRf+W1gyUEXGbz;8P&*xWnm#5=FYQ#i~?)r+vay<0-M7?UKc zd!E6hZoPGMhP>J-DK+i=)aRovKSJX=Vg921(Rfv=j@@DDIe)>*0;--#F}PHldCoAK z+UpFT-V}8I+A>O}%E>gD>JRCpBr23KyqA0C@h!aVlR}2#ezJfR#!adR-G#Jf319TCl3-*x2cfavM5%oIm>qpT!Jwcj%E-UKi(j#eva1 zqmIfdVV}|?<%C&psu}XUdy!)c&JHJD4=_=Ul1E^NV3<$I(yPS%!KeFw47HdW+_c~( zuEx1Lv0}PpQ_reJ)ez4bn%@5kynzrlPm3-;b_>iK4t-pni&B*z79P?lpWP&4uYPr{ zHbpF!___SVT#6l?#5Y3L!O@Dywot{0z<%r@hbb4~ur29 zsRm?D=*g7W)!@HGiWwVg3lB2H+c^~F1dP75+!(U;QGdrz&N_CaR#rS$suPZjIWXp^ zW0|0?XnJ?{tu&pnH-iFvH^(=upCb)ZTvxs+*`I3qd3I9OQ7_(Lv}XQ(lcrm=$P9u#f!gQ+zkKhvB(QDaky@s8YEhl{bY34~uW8}#54xKd_{O90rAsgBwCOVS^>5`^~FOR~K9ke;d_f|)FA3XLG zQHWXDAMk=hT{d5Kzf5p@;p&$tt0rMzIvc|%u2avkshyF#d%H{DUT0$>y*yJ|zoIP% zz9bOyR)s_|I7(b;q-@e=)S@!Bc@s1mu!s;)xq8rrNIuL-M#%8a!Xy@nP$=^ z6<*w5m5x^(8Q|xu<8JhwsX&$U1@da>W=ul?7ad>d#Hjbo%$k~6tG%gq$;~WkdpOy$o$cxO z_Fyd=UFF*KIsH_zv;w~>*nl2CKWgTg>&g7KgkJbHIsUSaXRbSS6-;Zy^Sy>cys-Q< zvZ_ff|F$}FSFcYQP!kPy$jJd$8HUv1!VqDW-boa-garjl}oPo1yt3)n2)| z^efdMT6YIVyGkp20UD*4kM??P!O`+9N6-D7%(nC`hz5RG=sq>mwF%DQN!D6tvSOdMJV?)wWHE!30bwqWQT` zpDJjTOc7_eOdjGN+L}F>piO%@X>6J7Y8u=_qI7;kZP^1tFO0?9xw9}C_;aB!NH_XI zGRcT()@Es5T&GFL{kqKej5(X4`PEysVY0!ib;H$MuO9M0_<}qfYTtprYn|feFuquO zN@?zt2{>Yc8yPDmwGAbD%UDB-X$boS}Ni;s`?sJLzC;!}FNRKMRf ze`GptwNdSEhkw{^xGheq195B}Qjin}b4Y#0E63U3W$HdoP|Y;KhQBV%2yNVfwU<1$ z=kFNJa4}CA7;Vok25Q-r-kmuxRq&*V9&4rrzrI8b5k``%X%MM|ABVr2d}Qul=a_DTf!4KhCwU6?S0*SH*BTIC|LJ$H+u$SE1xyYZ|rcrr$yOp zILbhJoho%#4~I~xtC*XzR+J9EX=IjU?U-8nb{^%ZtMhB>KDP653MUS*)Vn)|x29_1 z`!u_nO9)}o$;c=@>9!ULLnxDY-fZ!j-1tk>@*xX_ek3J6)L3)G3rW#i`^^YV6SBHa zJXVQv!=EcU?UE!)3@(~W?d&nRq^mDiYuodeY8tDxR=&_Xwt|^D?fcCS-!e{1>sUun8JKuN^8nBF+NKhz^;hD>Ya63XS+2O^U*3IurP zx7LDG@r)W9H2HFXH5|sb-$qlo+(>EUNrP{0N+6ZU~snY(sNw>&w5p<-ecii)_J}J}eo95Qk z9;sWOZ;3PzOWhV9OFS?Zi`1}Jt=jM)SZ%uLC+`hkvi%-Vv++>s?@UGBk6L>M!`zfq z?8}!xe06u4^`mq(DeBNkZc0qL{_P-T%zH!AS71y0W+y}K%@g}HtcO*yOvZutd6#LPu_iZh88ut71E{xMQj{iw%$A)F;nJn#~G*}O5E@ai($Y^f&HRZ z$H$wsDwV(jbvJ@dfxu4AkF}uViQ0lEdb+YPwKJ?R7+LYeI9l&3gEy~AZPM^+{m(2b zJsCZ$5HX*0L$O4QdkMO1gv~G2c{P80_YJlSnY;|4YmFM+I9zY6reFP*D)V}oX&|-l z{!>Bnz7uhVk;)30q6W=5`*FWeum0wbsouhKxk3}et7$b)b$`MQ`Zmew$9P2lvu?tC&jG^gKbzM)Uy`>1+lCXrfD*`-fVDDYx%by zE11glKu)|%$DH3yeEN=zlP({Wv&YMQBFL>L%VV093w7s_34^BbLnq2sUU!V|6f|ew z%A~Di_ICrsYA*S4GNAeGexDL)Hp;Y;=ii8Q&UGA|L}YF2z^rfjJWqBcv6Ofto!z_( z_j!6&R+O;1U0yZEsT1w@(zAK*rdrMlJZYh763xyWypO--458QMPSGJ(wi1~!T$nK?>Y=i&BC$Wzj?rDVC$L!*8B z$*K3DI=OF@-uF@{9A~f8$2@6QDYuIEttr=%{h+x!X@W2{`{vbSCdVA=NN+dR`zy@W)~O$K(Uz4B_E{zZ$$@d9B__y{UIPIAmB!s1zF;j4wP zWsN^bip>GxFfD0z18LH<^$%aS`<8FG$zr4 zZ`OgWpsP7jHPx1Lq38y0r+Du7?TPcyF%e42s1B`hy4TzGj~EzdKL|ZTYav zOQ|PDmv_o+=yCsC5}jL~PkUCFtb$IZIbXKJlB3lL*rIF7kd|VdEFz~td$c}{+SifY zwCzDT|L*s!Ld)nf{n|`!=_RGw>L+5_b#Gc037eyoPkPJ;ne1Jy3)7H-{!TnbtG0oI zRFT}?sWO_H19gLHuqgi)9b8@5gooxdhU~|@BgPC>_*7?a{A|>VTb~vMAz1C7y>~UN zaLp!<$J0!-B0jvB39g=D31$8fV(naVsoc_k_95xOy8T9#oFCT$+4Seu&X@D`C0y^t z#k#lDZ-XDcOPo9mR5A&jTsWxExC*V(ntol7d?5-W9h086U9#*y9n5v(NDEN)<9Oq{ z*%~y$Kb|nqzUnih7Ecx*;N}~{8Y=9Mip|Gz6mu$fdkn+9d@SS3Ow7)~49mK~StjY8 zWtA~aRX<1Rs}qIh%fR)znbgUpR58{?WrkZgftt{s~Lj8naklXPk@<+!!V|EvPj< zBW53vdpuDM6w>cTybI*DzB>Uc+VEW@vsNz`+$7n4!jNlmb0}+wfnp+0^KPgvQ@Am` zS{7PES-EBC&O^o>L!l5(>Z)0)N;xjt9i#GBBcjlulc;lI`p(70OtgP*CUcNMVNt&5 zBd*2#%WG=ER})r@4>BuEk=L@fhkA92RVx`w02?K$+d_vhS-se+^smyLdMYjNxjDIU7(RjVO2a ziwm~D%UP?RmXIk;sXmpWEgfO>jaG%`;1v(yB_6XswiGNTR2*$@P&ikP8JDkG7`Q?< ztt8x*wX&}6O~x71$nn9v^fITre@j*VDB92$d_wu$wLR>40()DFFM8b5P_Iz7_q}8p zxp*ywUWw_Tzb ze)^2#D=g(dzgOOOAP&%?uVLkY(~U`QUI5MlA~45SRxIe?FBcbwe= zB1=es!aMFm6c_JaErRy z3&4f}m@MQFBXSyeAs+_1hK&z8z%fUFSU|g1^M8o~SFSx|76FVwfMll)7!TjrLw=D5 zu+i~>2M1Vq1_D?R;mE#!JF_B?iD1I50@qdp+@sU0aAi8Q{3%)luLuQBPq>mHIN}jk zGT5ZP>%!Rj8tf6|;2{2@vopLCa)?EC1Ae*tmp0)w7hMA3HG{o_`-IR=`{;ii+mQ|f z9D*J@r3f5MF%Jq9ACwM>R5zcw$48inzm~1>Y7lL%(-0#J%#)E-W zKmEntKGSLM;3^y_AptfZ0f3z7cplAqrx|co1VU>2dw6Mdxa9??^5k|rxHlKF5BU6u z;m$fcL67^cbBV+|qJj5*@8Au+SzpsI@3u*5O)G9{PT!Sv;y1Q~>^l{iPDxWfA{FNE zmmg7qZ3)mqF=0EsX6XFOJIGie*Ky~ z!nGP`0-Hb}h5t`WVW%)4mcg^yc*?NcaQjO5#NO6L?f%~39^s$nxy?$Z9gm~m!vhUK z=tVdxiV<+wvB&?STSAbVXfeW7#1wM34`?(zgKInios9$KNub3J&Mx*|ANyfK&th@V+lZo_UQjp*_!|I zVu0-Txng*@{OLs~P`P{xU|0!Z>Z~z0=z?;T9cx7Ta2Ivr{j8ZhCdpjror}@wJ#Lej z4hoBG`N@_SWO?HpJflNK+75ndr2@lJa<&pnHhAs>IQB1**x}V5m22^;zkkfM2)RA3 z>l><-<8N)e9f=d(7G6OqN}aPX4XwCq^<=a9sqTuOHco_4qokT@I@$Hz?Pa9@mFtSY zpIqMJe3bWfbw!~(cF3X5cdyhm-iRGH$_1{ou$$Yf9-L){xm%Srma16oRu3{xZRJ{d zcvkCkw1eqyTE3(NhAbUM3=c4^|Dm&j@j#0I3pRH=)0tgnnTZS$Gfei?P=&2xxe^folu!Im=n&v&C{UQ9o@I$xkQ$WGL9PZ zz-rr!UMS9bM_%sRDq#HPZiUE9J>H*qF!irX2m-2HF+gPtP*#L+JisV^j7ucC`Skzd z^$b1pPqx>-M4qsR2Y$AqVz@j|ME0H8M}`);d2K>J6uSOv)cmF(&G+2AP)^kVL&?t) za_#M9^;^UB6mf#yDnn^j9Ye64-_=%L=NT0~W1ksQn$Th}|BsqPZW_!l+goN7z&^+g zbiDyA?$4KjAc3E0A5sY_bU%Y|2jR>1{rlhKg#x*cN~@GNjOp?DfFZ zDts4elr)ZD8?+m@&4L4={pN_h_xetqYTwb>kx5}oCh;+EQg+}Yh9j^i@wC9>hNa^rm5EM;I+_&z~hSa?O6^fIoN zlbaKjuCgGMR#yjpFV9^ouCiDVP^}5^mXJh0_7ZFn(G40It7QI5v|+C)tZnzz*w`Xn zFn?WlmUeN$-H0Q${gH#b`b&=ts^F|9b=m^X{!6XEq$e{pZ^z40HzODF9!X-qipl2X zlv8jv-Mo=b+{Uh%C?QHhQ`TPfl*9glVlL{ab?%aTe5yw640T42Iz-;H47GIZ_KM5X znqo#^_}i>xgkuMv4{I7r_i!glDE8kSa-XUD;Ce6A$ss6#sU>sx$VzE{cyzv6*u14e zsI|W3c0!=u>W+-CG;6%j2FbhY->Ug7hD|Dzkm?*GJZF`}N=^?b-V}lxU22sU*qAagNwhPoe@KudfMDh;t%m6 zR4A}sk5ma$c?0xKbVL@?`xPoNzkBey& zSRLITYmcbr$h#vn=MZ;^r~4_(_b=E#IP<>xc&Rx)TxHyHKC+p@yzbUo@mnssDwyWu z60;S*R%62mlY?jy6GVg*>C_nZl}pEitqxW=0jy|dN41+aO(QOAi95L+4sR=I2;$PI z<@m+b(9EZ+M}_01mH-BY-`!rW^wO~ z@ZF@Q};`P^3sp%c-1FR70Wn~(9)RxdFA%qP}&#ryc<>l zX7eW}d^Q)Waw`cJ zo?SP&S%#9v*M}dwT*_Af7YeB~B=rb`3@o#)6k8;%VeDktu z6=vikD_v#_%|k9>_hfYmR*grqkT(bW)^RzP7I+%HPj4Y`FhnBj+e5zz%0#TpVOChK zz0JKiNHvohCb{G$l-OVQp4_n8Gz^uR$upRX>3ARxtssg~{jjh35 zPru_B$YE9zdoL)snoqRDhU?E0%3gfv=bW;)vUo)g13kny(HMYV&Qr?+e4wPZp-=R{ zf7JeyzlXm39JO|Hq_A`7<$a8c9t~Ou8X!2UB6o@$SC`Dy^n(b`ihr9lmabx!|zQ*rF3DNuirNaha8C_ z=3VbRgVE5Zhu>+l-3n6#7WjH78CPo7hwLih{+<)y{TgE*Raj51^|ca&>~%SlA^e|8 zNnsCDiXa)e__xgs`NY)UcB+cI>wa`fctjdoH!eKOX0CDRHKH1gE)S5h_^^Ig=?WtP zFUc?rZBl(&@JOIEWeW7<5cZ6^)-}6H<+%MWxofJ{y&i{u$lP=|v+Y znrl9gV^R1rU0kZn+os9~X11Ll0_OM8aUvFwGx4W?Kq8ewq zQf(b{Tb?G&RVfI{@_~n6Z7=$E33ODQr6rZ177|q4&>UoFn!X=-qO>h3@g8}#*; zpoPl_i9|VGp;28b5vTiFw zpT4Xgd~P7(?a=OU75y$B~P4<4mI~Zn|kr*$&29bK;fm!6O!=f;(|FfrVom z8#_GeES-X}hq>P=>MC2&{0v#Og$ki153MA%2W$?!2H4h(%O*xp?^5M_TUadM9QBL_B?I-mpMBF(vOb(9P$GT3QKs{P*r%t%Fp@?)EEsJ#a+lF7Ok{QWi z5?@z7ubtzh78f2!6@ww(5q7iIMrpqb?R@+w;$p`rsP$bmMw%FRJ3Zu4lBCzK5G>>AvsK!OrDyH(*G~BJjr&Qy7rCXXmf2w$eaTv?VS}eR9zf;EP zRmyKN`nn;C$4g=kZ05kE*lBuXkZJWuYpEwJ1lAuLEOzPq2+ldHL*cZ==TYu*=9ts)WU_YhQH&v(_?u zl4Sjec<5q`8>Ap*Y}_bX;zKiYFMw{vOc9lbNoeRX=vx?RR6hxeE=$9yv-qVq^^^fK zn|@$Mss6KoZDN;$j{J?OiAm18By}8XLgDEV4GU{-AXk+Dk)yQv{(;}oDu{+8Omcg( z*9QfPi6J;@daT?}j+okV)?jj#OG-LL^w~WIl1wr8^hFQTH#F!_Huhx>!ZTy34+>ra z;YZm`wIf%Eri~9r1ur09w&!{Vf++OO&FZp`k}gLdvOGO@Hwr7MBfDk{rG6-R=q$lm zBl6^h$?4DLdcE=Hj&-Reh25~=_h$*?Qwubn<(2e;?@jJ*QEN9A1-bmB?yK2~ZZJ(J zh2?2SWuP*5X6J{}_UkQ;tr4M>07!ck&}$MLXx~& zioIK#>33bA>Ah1aoFin{8ShnWS<20|291S?W)>PbWSij}DE)oavK*65hn!g2W|bND zS+&2bd}*vdd4163*DxVCyCSi7ec|fE>-x?{PXK$!b-bB;77nGDGZ<^6fQL^tsx0e| z_TlToL$%gjyR(ap!BQEkf@Aa{3I)Xkr)w25H+0+L-OY_{b5FzzZF2ATGg}$efagfuRmUvQD9AeR{R*MlBSxIUd*k!p=r2e=eg5ZT zrX(m%IAdaFnXNCPUcHC2eiw=teeI()nB4oW<#3I7AvG( za8akxrTB37MR|XtFvKqwHFeM-Rh@6#Y-)VB!rhci5$hWMd24;DTeQ9z#M>X?AoB9v z_wm&7HfS2R#Kh+)3)-MfKb?UV{(@FYbLB&FN!%(UYlYfAu4FphgKSa(+FB{dWX0TP zVyv9CWa;#q&g~tI^6|@;N{gdssQFZ@#t^=I;(D-TuN7UcVExrmesk4=q z6q>H~#u(yZ>BCPSz7{9lgnB|E!X8+QdKZ)OmHio7c$GaW_NRvNK>iW59<{-6BO}D| zt*0VKqF)YlKhHfwVUBhI`!Kt`H@hL!nk37USnG{5RQkJJ57Qm1r21?DWY*r1ynsF*kO5g;tK(Xeq`UFQ){(g~E-Kmw zj2E^=CC#Bhs?+b%A83B_Ov`RX8{cqqjN=yRf738Q_QcXk%JOVeA|n};`W39R`+)w? zhjxa^RmsqVyuYF?OPg(f4kWfl<1(_U4@n1$lY{Pb%Kpq6?paxhp;st5(&x@sT80_6 z4%|MT|BPs=O=?b7t#;aJx{2BnAItNUUdL6WOCqau*3R_{9GL9aPXhff!F~EW5az_T z=f{FUHa&$0bj!w*Lu|CxDs8F>Cj!YFsge6TOU$P%o=RbH%4xj!SzlSYho+6Esk?@^J{#u!`axw0pgs%x99~ zb3bR9E3cea@fu3*A)-S~n8j4@d1CcirPg@S%6#mT_8cC?>`$MFqj+2bVJOpkK@Qwx z&+J;=mL3l-rw)wYhlU1)_nG#Nm(ES@7Ugmq{kUM%>fvSB&EIfaKhX5JOmEpp0fv#H zp(kE+DxfFU#r0mQAw`=sv%ei6s~R)av|ONaYp#2+l`mts{gNnl{8`6WYL^uac>y8v z#CO+rBGFTSiKv!8r4rrdO#Pqnm4Az}{D0Xp{ksxI7Jq((5d-k(ePaq#{=Gr1agE+B z?$&ejF8OaX1s?v68S(tx)gpnmhWS{)5KX4ru(swrO}UpDrkFd)EAzP9y!`rd4zGcT zg%0I@9P}=>9!}qjMMLp;prBCfydFX5JqEKJ^`Re&c8g_t=mEeO2etK3*Y^f*__j&E zjjt6^+{vR(tT!ns*RLqhu(H&W;5dl=I!pf+Cf@GeIRiq$OR?Bh97y;qaPMdjSrQ6n zAoRs#j?n?p=|M$EyeGmDzyFsggC5-p3V<^M=1lmGV1Q1UJlx{%9C43hx?|AORz=mT0&KAE7hvSU6&$w1-ggVb-BIS;UO?^VRk*$OL6#rw~49018dhvHa3n^+v6vj~$1^nxIp zD;Yp{v4;qn!yjFGw*Mt^2_;OOi@gD&_oux;CP4Zi9=8g_eib9~6q_#~ua9fbFM{^) z=ulv@3H-*T!-M?;{K}oIDSEUB5+qy_il6NTAU5&mb`kP~17t5Y2@sPhB2NgRSDhm8 zbTK4I9!G}>Zy_Xv{*zfa0r%ogPxnp%rI7IUm+>{BvjxmXGIW_6b!&EnZ%5covDatd z(8xD2DCK<1MRB)quT1=fdFxc>*nsVvQ8QvC@Uiy0#!&~3@nDsOK zh+{S8o=iZdqNvhaU9@y81(P^aWGtOc(>8q+W|tNVV+^OUJLLQ`whv^_8_4gfNVjrx zjOuCwiHbY{yUg6M-@*k!-(-{+=8gF>TSnr(X0NKUj|>XQ_vcz z^JP0AW`VfEbT} z+XvdKss`2-_)j;JKRv)H0xHk3lS`3P{0*R!5ke16FKU5HnWEdnpM*hA9ohM~{Lm0w z@8F+31Wp=@2eLH~1r4SZ`nH8fPX;rSki;TvnfU|OqBqSjcu9}K05ty&Ml2}AFgl9_e*7X?JnaXZi#K$}1o;-VHw2Sk&B-V%g{9*~2v_C% zw#*d$-Vq?W^K4W^BI9w`W5U5{LV1#LL5WM0a8>`T{e9F&gFoZ3;h4U;N0b_x)q?A` zqDhoC#%z|g{V6pw)Cw8*%_?4{#^>z#ZijsW(pzVz1V=!gbrlFGEl>V+e{QN^n&&ow zr5U?}QOxSy!HD5D;hDIZDKzAOaB)GvqVNaD?dSHw-+{>bAj{*kULYM%`IiWJX+H#% zMPnd1?~E%9Fu{RXn8l7R#DE^d42}Ou1=(_kvm7|0Lr(#G70V@M3ouE_2ks#*4&w2@ z>8I#1k{6J}tFAnGpo7KZ>46DI8o14h&$b;;PS1k&kSxfl-mwGh7%*eu=?QnIka@uM zgm0Y^t}M^M=ZeS}whO#26bS|$<|Wm;4twF%OO-c6}|qkJN50`f=92C`xtVL4GU~jioVsv zS-c=2mEM-@lte>;)FD%t814v)0AoS}d$BCSGK4E;rT^TqR&Fx`nmr)z2LU4d1hDiT zatSB1cbXv9DH4xIhi3udf9V6;xTl4?c?zgel-fX{f9Pj%)_kCUG zdG(w}SDHf;>FNWvGLX94`#S(0aUR%!1h7v7vJ3>CUrhuh>1- zP%|fOxZbnMB$*N3`ybWCVZft0e~MDKw=`y0#i6V%&TI43A53zoRsGHW#(?zW9BA43 zhh-4cna13@Dr9BrR0}zUIxyow7DiKz-MFkqdS@nQAHnkKzN~G!C(^ZNRtt%rWh@$@ zu4ER0^NUd?O+}s^S+&Qn%v6X>NkLLRTvgfBW}l%ySW*s-IqVFhYLz2j8|lB*B}S#=jkIicQ(~qQLKHRw#qg+Y$7K=Q=*9w7p1wNbAKP&D8sf93-DI80OwwG zk7IXZ8?SEr@l`)OPl3{cWm+SDU|cot^O8L}U#d~IFpM&wh(B|4IaE8Ya%n!Hx&%-g zncC|~{PV)eZOWx8K(q=;TUFYB!rA#WpSGK$HCWg$q9@ORC^CvMV-;DPphlGIw!?nD zA_CIh&Lmg9@}Sq+Nwn%&GYG?YmiGT^aU+i*Z5aBOdcEgA4EPi{^s2f=`@F_&HH3PG zj4TmgV%NbIVV1PN%!O89E8!Lv4DC+AzgspOOYWv|a%XW3{G%zMbJKeeQ;QD0`^6Uv>X#F*;u%HL@RjfIsM6x*et zh(6n!px&+PK2(|^Y9)h27AMWV6M`T z$EB(Cwf^t536K3-+(y48qc=d=4BOZUv$ z+%Ia8R_nxKyNJ{n*Hs@ORc(eSU9D%%GSKFlVg>%r7G_SAK@AvW!)XjdhYEg862wM6 zf5zA(7v~Gnm{cOqKU1=lhGYPH8*8=A#0fJ0ylAxe?aFg}f16B&E*z zAZ9eAs%!~;IY0moMJa{y?J1%x)ARa<`9=@P)ET@3yc0xMAYIPMyJ64gk|f%jGhhP2 z8oVo%1#%C*O{@V|N9E3+gX5dxt2=Ga8&3du(&0BtT=k>hIlI)fVn62lZrlvW${YX(g5wn^NaP+VI2-{FRvg!A3BiNx6a1OPJ4eK8lk?VThXk zf-zZhNBc35yOD*~ux_PSWHP3w;Z=>aUNLDEQr{a{ErLl&_*$I1s#0CO{^OU=v@;*m z9Wbbi(T4h^_Mc1PW#YjkMD;z~Sx+ikH<0iO7}dcYg+bNfhm-r_ThI`Mi9?qCbNxHu zh-yNF7(!3qiqvrQSd(!`B^N2G@WGRTmgKL)h1G6(v9#lnkDi=c@xFgU+Jl>zX^KSM z*FmT}u;Ie<;W=OSi=ZJX;rlSJezA)-D5CsBXTOl}Vq?q5yLh!k?Avs3NXFlmg}dp~ zmR7?>qOm?B4VP?-diIHLd?V$D(6(d-f9G=2_A@$eYS**@CQaQBle)Oh{+ruLDkNt` z0U)mUqMFM9B38{)=;r;bl4iTEBFDgZEx#@1zOZ{iHRI=h{C~{5!0%nv91-oD`ZID?2=7QrF15y2>8^iFM0;x2uSclgHJQ19)xT2c~oVP(kV_}=Cln}9(; z*)qvz3T_scaCJJDMe?EDX(5A@p#(1B)(_9Z){2$S0V;$nPF9;lieT&B^3sLmmVs_jAn| zrnV!*v(llrGBr=>P=0hR><>=yS-qLkwA%3L?ks_oV6*t_92O1(6g{TxsaQQebd*+d zf=Nq-C3f&;>a^0w>K1#{j9D@joz`;GSF^gMf;JRdY%{(|PHi|h^IMt|=+*m~i>GRsK7{GhX#3qA=bJk=J31=3Qauf|`IXJT z?R+{xgDo&|d3hLgl%CijWSB6u4Un;wH9V!Ld*+r?wUors3v>y;F5skCV)LGSM4 z35upxJT-xf?dcQJ9$unj>&nQ2SS=M$DOZh;N~eh$<|#|0YRdTC^fmqj=0AbV!(Sna zk%jt9F2se_G5>zAg1emQOJ`NV%Tkt=IvL+@uGZJ?=1}EbU^k2wV1=%tb}|Z#nAzb8 zqT3pI3-HLNDbYZ4n?jf6^-EiCFK0i9YnQ@HYs3XirH4~G>0r@mo6S3(JS@)Dh9qDs zePTnP!~fk`W@e{t!mzNK$pCYAwwYR|0@)(7nA5aUYqN=e5%B%QN_pX+c(64ueG6E!#5ixc z@4u`OZeYj=Ee8AX6%>1>js1qEoFb_|d!>wvnrwV^KYG_qhn@nC`LhzfG#X zIMjPy)Twz|(r6m}(&g~eEUwcBr+Bel{R#=@%^~r0tGoES0N_&Kex3pr^qq`5(+kX` zXmoUEh70`eeEnnfxAN5eNc&2~gpAjpAN-vQGwB!g5l)Ax`rV-tTu-@MVy4Yo^m$6H8_ej@d7flIFULMbmBS3FW z5GXF3Ew{zXPNv5F^0{?b3ebK+#7KGk@NG9qiA0kz21h~LW4XD3KFr4R&L-1K^W|ik ze=D)^w+w%c&%;Zt`!Qq#Ad%F}c?wnl&!iq#)Hmgdga0soj5+Tve4Brj#grm0k^Z?= z7a>^Hc%%53n|(?-U@WA?if4%%>=T?a51TqXo$rJ-QxyH`4yFjW3C{ zNB$p1#WlqzvsgF5d>)Bm=l?Ls?ZQo*osLtywCP*v&j$!6NcG0uquSfj>D@qTRW7zK zV{Tl7)Mje;ifQ9MoW^kMyV-h)ne4XMVYX7g@0-D37$gv-z|5V_}hcqx}6n0ec8aqYoP z#0&Uiw(VoY^3H3-^1VpKt4|O~4XqQxfMjL&8ggCvKDQL-5j=C}nXJ`eF?AGFHcLpn zVkf|F0A z*r91)?+80#^j6lmH6_b#gnXYIsiZq;C8)jZ^U~Ttn@JS2ZZ7ll zD!|x^$G5vQKmMzlFMo(2vr~uJv|DP%0njVvL@iE|RaophDWq(w!`2|?nv0{=?`N&89fUBW}Vkn5o|u~dE2|YwuHHxW|Uz* z{des7elUwV(7n}S4`|x}`jo$X`x3&^>^N7qJ-Q&OLd+AG%)u8)K(vFqh{hM&xH~y2 z6{oWt#MyJ81-UsO#h;F?*X(|1PCKPUAq-98(N_LbN1_)DHd$4zZ9{zxZ~W6>bzeqA z)wXNpapN5CRwny6b-|DLFj2Z**0h49E)Y>A8$bgPl&BpK z8Q$ypCMgewbKU*9br{DF=3qIF9gsk##*AlEw6mo+!<#{sXaf|}P zRfERtTu&HNn?=Of^d~K(= zoiHIILL_jF1D2_%sp2)ppiT4BC9A-Uw31_<&kb)7WYo_`*SM=(=2MgxFF+PO?yIli z9JE&CRiQc3qjsEWV&ZJlHji~avQ&A#|Bm%0;6g)ei0%?yAL*K-pPi?OP#yuIMNbi` zLw2K{%y*!=g!#OqTU2S1gVRn~SyhIKOU?zs`r~iXs+q6lJD_W=%Tsn$3fm_#bT%H$M zYZltqwAV^*dRtpXFFa)^*Bg7(uD=zzE&9j{@moz*63Ti*9_qwu-!|AO5}kjesEYOU zh}s7Gg{|=`6pRstu=8U!-zP*1^_iN^9P@e)mM99Z!OB52iyF-%t7;H#R660YrXbzi zPG?Ti*{w1vdC?+>M8hIub)r!t;kb^qiLF_RYS0>BaT$;-+=!o8ZBUjXYF8Rvuk!7x z&c4AzE^0`;u3;=^jZ7pBRje7u)sqQZ2}{oE#m5us%*~;HLvPRUGIB*vzQFH2<1g0G z^hF%4kRKzQXrvuS{mTN%oRa1pc4=u;JzrlqNQ7n`j*#aSXa$u5w6hF_8Tcw^;*tst*f&^ z=cc`6_!0wU5KYKjKihU8G3&{dv#)Q#{qC8S5?)N2v6jM93HEa9B zrj>bF<&=`IA@xL68Tt~#hED!pVfH<^K_{gtehV9}T13?-Y$bN;bZy>dmbC}3oY>(f z*A9CEY3!MxFhZ0kWQ`lsl)LY$%G|NtJ-PU^V%>`rJ5}Lzp4?*kDNyA+!|$?1Y@%}C zFZdcJ5&Z2bI4N(EJ`d)T=hI0ohyk0-=%q=HL;0YldPn~VtOoC4yRLAt5}t9hPRUjfAij;mNv{Dd{s~`V_UCsr23x2-7VOmUxveRUi0vN zYLSFhZp7SVciyPJ4D96Aiw8TQw9jc8T509kB{utYhvNKXb_y(wg025tx-M&&_fglH zxe91INT+1KwlJQn$l2DTiq&b=olUI>cn&7Z(M7IU64}9%45<`hQl&&wnQN} zn;pP4>i;kfK}SRXVNCm2hW_ID*6zXcJ$e3tg$dy1yGKNC&c>qvv2>O-*Km zQ2>eVTGar*a62fT6sWNlNMn`WQLN8wANBobQqE+QGM_pFH}HJ8e@ZTOjHPB~w=1+) z)&sh%<4Y~_Lac6=xmrZ<OYL{G_Uv3v)`bT2=V); z5dVk7lDXVrX;HCE>R{QGrus`XI_aIC{!{38wYw={+!#IGhi7@;q&Y{YELL=cEA)=a z)Q7(#qO)~c?DDT1WVkZp7 zsewdTA>$o0N~S9-8k~0c(aU;WDXZ%I0c7E#u596RZ?gte$4t1^zq5IvBgCqi-|)1) zM2jZ5|16wI*si$W|Cd2{uBK@jKvI0RsRb{p%{s|sZ^q=Tt_*absUE0!SBHj2&4pE| zS1+&LJwf#0-VyacwWD5%vh(c2?m@h1JDpPKg=@sGi51~pqK=!;3I=b;x`}nFL`BKg zpH$@fwze@Ts93D3-*uWtaRFZa5y=ke7%?jLODf^fNP-C4f4+gZa?ML}I);Og663+` zS-XBwB#x6hBCRGQGF}}HLKlzk@fa)^fQX=VI>Ro^^uBb8G))0AFcsSC`>_SIex)J* zlB}x(y{h0y05$+>kFXCX`BC}#%)A_V8&*sgAiI9$62lZg-nc1)**1N;Iuq{o!maU0 z`jHXatsPPM5R*o>xWZn>84DHW7wqSQ#Eg93V=xmK=;Kvn|A?MZe_5x35TY=iP5qy^ z_y6}rB-O^R+|inVVR!Lp#6?_)E>&WkXQGi#^Qzuh`I*wT9ukOhc}G^{^$UvsPu+!POv?Xcp(pUM&bZ<%NFRn!M1#X$S?7_i8 z^^7LjJ;uIWTnWsSy46v->qfgxHni|5T-w!iQK~h06&7RYZ?$We-Kq-L(^jgvsej{{ zQ{Qy>e{+uS{s_`hsKIu965MXpmMb{1k6TnD1WV)cCTc+?6w)$E2ej5wZcKi+Q@@}O z$7W@dIB&Z%dfgc$bhn~e`~S8n64A5u!-DB|Nr$PU+v5cmwujcHX=7$@+BzjhqP-Qo zwt>K^*jN{jPav+RJfim=!o>YEoO*es$M~uhio?{6SlepPn!E5|ZcKE^qO?-;MtyG| z5|oGk)xT+4Z(1?8oA(pWA$LvtIiT9Xe_^}K5vIECiHUz{jd2=!PEmn+dIpZ{T6KiBeiN$iu z%P}|v2|NvMAg^kX5pEx4rX)f=Tcx7kktBr+P_5)@72nhbu|LN3&#SX~G*Zci)B}{k z`jy}|Gq+5*sYDzwQ?y2t{?H)~V5pcuO*~0GtTnh~ z=3Hy~XH%Ia9`3wrA_PYwPh%KibF1@qTFB#++R+liKTO|uiI~v&t$Cf{jnc?TTFT(I zmr>1h4sTeeYjf3`X9AiQ$b!6&=FR-K^TF)mpLR1K2~kBCn9~l6kQDRhnr~IGbY|-o zDJN18vG+yd_56#I2@|b8S&eUPVq$yrOT*3R#f}J;d64FO$@|szQf+6{F!J1*l%-%_ z`0^6i7oHm^xs6^!*RL#Dp4TrAdqKTUQ1u%k=LL?gj_A{x_!BP3{)A&B|NIE#SX`n8 zV6A_+ATiFEAV}}-0CZ|?7Z{UX()y=Uja_yzF|VKy79+$`<(@Y-u8vM33LdydC#4Z~ z>&5}ToJteVYC1!qg%B?CodB&v(iZV8N3o_YvbAEiHTCh;i^C257qRYi1}6y6!;mAv zw|CS@J))Jivs6mXL??D!4Bl_BN}mV5AI98x_9_c6H;!ollj0cB zITcGcglm!Qm~H^+$;MBWzaC+l!#p$COvLiENi>QPB$%GB+9MOrWS)mO@16IeuVbC4 zdf$1m;fHNkN3d6GJMt~^?KBkqs3DDz#`t}U`;iH;(I?#EIPXPKfayjpA}^;^{v~*y zomT5V3=kYR-9(9*v9+n?7e7WxC?Rs>P+Kd230f)+8$mK6lfu^4N~{h%)Jf?a3c|ar zYn=1+S{;op(}5N<6oVTC))}48Tvb_&AQ=B)xNvJBP0xIqr20x49UUtCqUtVWZyAr0Trg#cA2%j zr~3OG5(S}_>T*^7N4YXwdN7s$0RFxa&K24k%WYm)i z_sE;Hul*!ep2)*Klg*etrp1@RHt=4>J;GvR?S@%CCU?#Wbgz+aHXzK+pv0P9d>>29Wfm&zU7B?R1UD!g)xf)8ORcR#iRTM={AksY_nPRt)Z3}XxF(2*w zevQwa4knVQUeuXR1g#%?QnZ#M*V>(HG+*EDg35DA_v|`9m~Bz5jlkY8D1RA~0#nb) zU@U7jzOb?h2pbOi595FRCpW>%pWEWYGX8LMT0|V~bv4X?iAG8fy#4D{kaW=_mJKj` zko0|#&~$(Dt$?8iLg~7LBp>%mq2&nJBX^4_gC~lkm{Wdbt6y)l~0nj@N9# zhfgqE9pX&!JfW6+nDwQ1QIqMnm0iy@jAKVVV`-}O_&j}o7 z1X3w@%kxuQDJ{c-9pz_L_J?Q*!?2%V&~bS?+;^BC1x`dKU;N+AQ@iB(4}0W=gm(Rb z%St)^fuot1Qn8i^21!=`VW8Y*c*}ChO@(<`)N7nsGRo0^*G2?1QCGbM__EQOEK7*8cUq z2$|0pvfnQ~)Rs01nlr02sNuwbie~|l{Y-D#{kg&y5>rss5L&pgi?)EBp-jAJpH(%&c(}{_s-Xke&cqbgY z{hJ%wE)m*5N9Fa93r3d#4=<-rNA}SD+Jel2f`4bz@A73rErOyXKCN!VciTbF#Bvl@ zL3YN{T@q2sb=Hrog|K2=&g=xQ}Kf zH-_xTcW$r^=rze12U4ACt$D6_?r+d}OZooy{94nvh?cZm8<*9Ayme7_5*yrwB&%IP z6>~};>fty%*DQr3o+8Z75ryOOq0T>L#tq-3D{nZA(fr`qW!z`A4)6l8)oz+i*@4Xp zQcry|*k=vZvb4IX*Mby78~&a_%N<#9i;rMTenXZZ)DER zxLtyfNT4DJ6{7(3KRpL31e*?@t5OlWJ)@Tx@YW~=!B^eeCO6?nsGmM4Ep|(!`MR!q zDv>yY>aY08?G^YTc?G*3`kxg)&r9*}N2h(Ho|MymbbS4GL{9#8>e@nZ1z$IFs#F8q zz>%+)vUcI(Ssp==+QnI7illCK-USF&4O3}<_eFke@Q3&keZbO_s4if3vJ# z_Kv8nFeXaZ2D&)}JNSNrh&jzOC@ddD1q@T@6IFcTZam~rdPTSJyYnT;*eS*zv~U7Z z^N~nTA><9N6rDE@yC>G#ULbKi(vsR|FYWoza2IUyr*7k;XSAI|Lr4tImu6v5OX`|E zjlI=l-WOOICmadR<5*Gd^(d&$%(;%&FAp~Hm=s!95*tu45ST1=M?7UcVS@e45_6pU z$LhBTX5YG2P1)5si)^hK0GML48Bk1OFs5NkQkt%ldZ-EW@A|x=@?S#47PIxX+*0o3 zM&hTh;Nq9JymFMAn!fXvtxMFDdB^1)$hYwO;f_r)bh1UT)uX-x`M+Pn!(Ig$f_WiP zGOWkA$YT4ABx5X)fy(#^_l^8W^AvkM#8_2UHYmuj z6XTF^TJndA$tE1+n|OLPxqJ$w2@`j+ru6kxP?)F6pCk7SPws4W^&O*`cPe}$siD8P z5?c2jel*y2_rHdGvmBFXP1$vVB#sf?OJ(B+XLCn!Agk(e|BdB8o>Aw0ZdEI=8X@pRu+4f6mD*A=p0gg7Z?)sXu3p+v~Y9fL+C26su5t5cQHlb44dVS|v9>Y)Zy?2*Y({hKwH7DoNqT1HDo-3}_+Yq4w*l&EE* z4(>4Ft`?$Ng4?=cC|QsNKIbp__%eH!wg3m@Q$zIrky>%WrNTd_pSCTRJN{T$ZDUO< zX2p9v9h*O(8Dms1R{-y^Osn)iqq;EXhvHB0Yej=hY06#Qwq z&+j`{(^`EQ&$80Q1zY&GxLOJK2p33y*83oK+!B>snoGeTG{MR7UCnri}g>1V53=Z;?qnIxwy9 ze(e=dwK7?t`UUM+XB>DAbJ1a84&Z8Y7Z8I(p@DL%OO10|4@CTYK#pk)-FHmBujChf z_N!j*FRR-UUh_&_Sw29uY!UcJK77DzW#KS#B~g>+@vV4*fQ5(C19Nadhj}Vz-<;@V z`KP#+BEH1{_XAl^Z!YJl{Z_4M>_)qnQslf6R*vA$k-8X65ba|CK9DT4fp79phf_@W%!cdH$e;}ua1`$eSE z5^o zTL+jun_hvnU0=1)t$KjFjuUz#nEIx_a$?E^*coRB+ux<)tTNY+iX6}lVj;1A7Q1%n z%Ubgq_tTGU&%73U@;JNUlM-=!4`R${j|HdYsa|lF{|bFUXqR>DckZ?&4WJ~dz8$`L z9FNL#iAVJ=&szrVi;esrglu9x((!-Nh3yx#0`_m%4%NKub(^LmnBE61M7%S*lAw>$ z_ejLaP{Ug>5=|`CnFG{EviU7ivWk-0&fTtvpXf&CKPz z|L`BiY21aQ_`+w=r;U!W_L0|cpO(Vn$imtEjh^#u?*UCqf7oFH_ zgq{SoovJkOHjG(ydBp{G1yDGokFKq*6P14gd%7DaED&OorxvHVB-LuzRHc^~<;_iN zl!=$W@n>-YnV(ucpbEY#oxWe9I!|Ogu5Vwm^hb|9sgBXul=dNWa&!*5eVD%<9fC&R z5YWlUw||(i_Y{f6h^5d;HlqK#oJTR?v=VH7oWe_$keZrI((KSkj4W|e%ZCC16u$+L z>bVP=c92v{==MQ{?iJ5>SR&+ZygY4sxSsbvj4V{1`X^FiY(^dV-mhX8nq-&=i=xk* z$8NOk!2En6V@kuke`hYR<%1&fm&*4*RpH!uZ6f7t;%hz;<)(K-nt(mb3*|^LedROIp1F zQ_t^XcN&*etK6oXKOWFt(kr&NDCdv6y*5hO|LI=qA&Rk#EuN6TL&1}sI-<@giye2k zFL8Ox!8)JPnE~5L1gzV_KG?^6`c#`i_hdg0IQbk2_PUUoyBzO3r?K@tt>7yovAFJHcvM2ElZqu#?C*tUd|Z(>G>M6Qaf zL{1OsAV(?)hHTFb=d}Thi$^YO1v<2mmbXW?~QM7d*~cJ-liviwOyAX7PnWR zwaCh!dz-9xaDEz@v$f$%OqUtl2A7&EdzoZc)jYSEeK**uw(7ZNhpN^#RO1%)uPw^u zQeCNOuKvo9i+pcRuXmBJ`X2_LkAsi51`Ni*HU}2=;ImN0Lv&)dmY%)Qc=1tA@P&^5 zd#2<4-QVqQ20?9c0rdZ2C_OYM;zc*_{fi$KKZNbEXGaDarA^R_?lAbUadkKjJ$r5-q6zE%aOla*N7^tyN6XIEB+tQWxsiWFgJKiUu z`Nz1+ZDn(^N*Ve*K~=noqgV;WrN4(7Xb0BMwAwgxnNcxMTn*|bC{&NR1vG6@2yG!U z#Tl5H?DKo{vBrIu?Kt>;;91k|%*7uwq+~XGF__dSXIuQT`14Ft;WwtxAJI1ZI@q={ zL_UH^9#CLr@;d0{?grxO@A0?O;G=}l%~+?v@P`(=HvHtS9~G_XpPRHrL1`04N;qMd_hP9OdtGU7tmRjM7*k8g;?3to`({&OwZ5VRaJZYHvfmgD?8a} zW3al+Z}Qp*N`XDo>XO#eQ@+p@- z_{@?!Eb7Y_Q4F+E9NjYMCzBn!6X|P51a!*pbumW{JOU6k{~XI>){yz|nbBap*Kx=6 zJ~oOW6Em#9M#dqhva$gjGRanEp#_JJWz4h*3H}zh+O|rET3)>!|0q>|Zi(2d$Q-C= zH|@twQ=YU41*MmNOppc)m>{DwTSLY5Mjs^>-HfQ5PG>6rP0SuaucvWtR`)Tq8WG?5 zDFOLo1O%AYCQjMO3^q*nik{K$-dBe$61=k;lH6=QGe#JIT+CyecCpu9?o^a7y3ks2 z?1+R38jNSFit>P&TXUHm`|Wu!iPiH-DVw;mBS!*d6SZ88Dgv+eD9qZhDgb>)8~aMODSl}Tg$sG|_Kza} zVU&0#`b?AT6#eL0dv8Q*k|}gnwQs1rL*)#4Z8Lubn>T7N$V-77$tyIAE%@#LaEABw z7J3_nC5$gFXUH03eeFZVeaoEFpGOFz?nzRi^dinAN?UxZF|C@776uj_E$^Gie1Q5;5YlqRyRW*c0M0xWOd&FEcr4#MjE z&@b}ml0Qi%-P0X^J^PbbsPk%Jx?q2ua|mI{XgFfdl+vM&`hdn5<~CadH@a}yZiWL& zOKEx*7Epu48kX5h!FpLXp%kYug2C|2dDN;zuM}+D;Dn5Kz*ksrvLSJxicNI zq<}>oJ20hk)d)g$UllsLW>BsB&7#HM?Xhpbufd6CYd(K1 zDven9E7;3@qtv!YOj209=ajwPQPXAbfK5DISEhPQBac`346O=p$P5=!vuS&mrv0YI zkBL6*oAtqFI~N+n>!mGO<^K?6ldL2^y-~BOTZ~x`h3-c`+LqR-LQ{@k>v)!@ObSfP zS?n=#I#97NB`t^5x{*9rZRATm?qvLZZ2*~F+;sQTQ0OQ*;gJdVI%<`)sw)`nH1xTA z)kL%uI@L0jV|02g*Eno8+L)NG25=P5j$UCf(Io)ae@myA`U~{b*q}A;^Y|Bj^zeMi zCt7XueIrYpm9}HGTMt*IR!td-uRJT5X@DwG<%C?IgonlLiO173^FtCp=NLFZbj%XL z@L8Cu27m!qR20IW!Tb;76UJJK8^$Ks2M`UJKYODc(9od8BPl=n_Zr_R!1=Rl`)=~r zk=#mHhCzSOV$612>RR?LCqP?IrE*-VwG&|6R`WTw$ZfL3yR5UD&DXS*{A_u_0Gv*8 z);rKsK$%=ti|Q=NES^y*&d)XE@(!#cHi?`h;jp7e<*mlLZOAP}9vP?T@`%X>l`K!P ziVj{q!^=~T5wN14{_HpS8jmUW@*(2FAkJ>2D?xh&zNGjV;?f%f{)t!Tg?;@sz8z2TQ zO1>d{4AHmlHucC2UKN7ChoXi{iP$0`KNsT&;+oVJ>5W;q!=4@aIfFihOqd6#7`Lbb z0?ZI;tzLN|&dY;=2Qnx=EL8?pT@PE;j;?quH0Jm#BGK&pH?i6f%&WPCToY_LT)nsN ztS@{OAyD)2e%s5dMM0B#g2bDHBcqr9`w}G;NZl76n=Z@jrIDTJyEUdG-^Ia6eOikq zGLAe6hK&+>@!OOi3c}I$7Me62$w;STmL%{o6XJt_2oNl@agbX~QY~7(R4cxe@%-TT zxucJyNS<$<3kCT`4Qq4Vm>Zj?Rl9*vwxG4Nwt$XrFc|z0Grl_+ZN4J$swiptXb7-L zwWb*yMr9iTKx8FTUk?~;6?H`oz7~tDavsk6$5OX!L>A4&G`su;j=&V(dW-h zm$F`r%f!atBV#3aendS82#(w0A6TFjXre~${n1$?#)o8&yLywhjsIaZC8#xc?iP^W zoQCmle%v+gCv`hY3uGd7SUz~z&XUmD4(c*xq5){EA-s^ak@Xg*xEG7@mpgm;Xc#rG z*(15x7KCSORYgT2=ZJ(&CX%Pti^0ojsQ1-aO%1Hpz{`LkQT6s>5L1= zzOFspKUNAiCOQ-fXbu~4Op%%@=lbN-65`0c)-%6n$?Td~2u>coVU&R%Ui{lrSdvOD zpycFu%DY2S{;Z;M_;F3NOQlX@uBHcTf%d+mPggl4>`FBwx+vtYhU*LYr8dc$UX4X1 z+jVRG($tQnTo@{TU{9R1&|xqvErSxLn`-;e3(ykNzft9rk0HSt^k5)pD36f}L%-18 zR2kQbfzFXN$bdAse#IkPl)tn%?J3vLqMJ6=owxxb%pg4wn01iL_%5s~csU=Q1z z!3Al7c4spq!>I%}Di(2a8PFz`nLxf&yj%@jr^vtaV_VDp(V0it;TV&LAS0QFg~fOF z7rEWzI{F4~y!1`81B@ICQpprrWmU!lB29c;UP1*S_A6gzDt~8cP0DAw&Gq%Q(p2PV z<@E%dar}^00>$~RS5^B_X|W+?ptDaZ3ReQo_d(EBDpyo_O=jM~^;=MC&+!elUBdsDYpE+)D!<7UAh)Sc-OD@> z4&T>w<)?OY7Z+e7u08&pjErZR*rEjVdzFk|;~UD?^!9DHINbxq%8i{$~pJal34nbUF;wnzUxC;K|*RHlC3m)+z-op%$Ogh65vg#H$zR zy`<8?`!10#Lfl%*GpZd=#&e_;mg*8YUn}2SDOz2X)~GeD)TWy9tIhckXU)428}elj zS|YrvKgWj)98K`}B#xQB_H+5{W|sb9!p&knr-;ZX{&`O1(KF2UWUTuL$IyR;(GgO$ zsiU;P=U0ZWXRrJ`udJz#W@TM#>A3AJ@rc`)*5;|Q+La2M34;>UV|=FB0pK27RshLB z2@!i+n&yJAkm625)h$qG+2G^ULe)~x?4+oRY_P(^hY*hPyN49OB)N~2D??kRp&N1J z#VzFyoyNkGw7hv!<9D8w`xk1%%QwlpdbN$BEvLZa95P8~x-OY9xK(D#QnAh=424$KTNcmw zOFJ0tz~8VXs4|F)Xw@z}3UmsT8$dfuV~t9OH?>7BnkS@lHQ0l%GPdi3gB#ZMdQ5^b z#u#e~qSp~ph|*GRnun2Baj6nof3BGa0{h9xade5dPC{uQ%9hYSnI3-hST)gc&qmv= z*+3$`e`e`NTVq5Z8?sjBf8RSlHq`>V*^z3wsRvfGk6PVXOAVCE-IU=;>f3O9l}b+Q z3?FG0^514&!tioZ|CevAR= z7=CO+m)O_RN>58qKn2GPyIp1dF2`>!GDKE^p%(gvCkio=AWrr`wRc(K0s5w$%-*c_ zZv{AXNhpNCXiVXvfY*F>bBwube9z=i{TJMX7ru{ByN>$pp_2X}lFPI1U>Q!Oi@yvi zW?sh9#)6o3Z4)1be6(@X=(QRpW4#5~Y6)T>C`v-?TYiQueew5t0CnQ*P9F(SMD(U@ z+mh><+_MNzZLSk?htGV7iCp_3*nb@^l z9s8RJzdvSrjX|yx6us|7xjCord>umClO&fqd1%&1FnT;CMH(z4tmm{*Cdy7OcEJ^2sUIal{6AIs1L^CFqH%)QP zT?y4)@s$YAdrr=~x}(vR@!;|YaJ^Uk>RJ6V(h`QoQBtBer`uwq2%OUgA4`A6*11(a zGBWQEXYLy{_}S*{N$vY<%*U(co8U2F(~OMiPB*ZdDw8#NWkVx^-U#Z~AuQf`T+wdC z)Pyr4wM1a-EMWOC?2P?E(7uss#qES-^a5)uCVrHPz{9A+vredPpb7)!*gK_YCeFopp>rUCV6J%Gqh bMo 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 z zNcv%-nF=MyiyKPgh;dlb6Alu&Rd1SRzRetQbTD53CBbJA{#>#~aFX2kuZ43R?JGQk znS+*%H;K@m- z*VNm?9bNb3*^IZ-JyxDdBF44MUT@x#-)*QfB}^Z_X!GY{aTycI?qED8GTJJ#LPJ*O zQj}~iVDvr#`%%=ds-D%emXx~spve+@`-3A7zKqp@^t_V0*Q1maQ5S}pxTP$Zas;iA jX*PXidR0BI&l<<&>7SwSM`X?a*TIzk?>^N0JNLf;%+ZD4 literal 0 HcmV?d00001 diff --git a/docs/static/img/android-screenshot-certs-warning-dialog.jpg b/docs/static/img/android-screenshot-certs-warning-dialog.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e92105e9a3150e3468c89f43c6120abc76c2c27f GIT binary patch literal 117060 zcmeFYcUV(R*DoBqqJjtrQl)p0-a+XdDWMmoccgc6D@ExYq(*uN>76J=I-&O>QetQU zk^l)LoZQcIz0dQW>pkyx{y5k9{yMX-J^Pn6YtPy^ z{4-r|-gu=H;A9T~XlVhs2(Ax`HCjju;-PX&)$@Yc98&yFOaUm-KQNEXiL-3^_hm!G20ZstmPSUM^R7FnU zu1QJ$2fi)xQV3A|;vblmK>v|M`VV}MfOk{w|IZ=_b%{#86p$2)$sP)f5!0-stHlt7$6zqib&r{eA0gf*dz@A1^%>`4`3}rZ4XOBJ|ha zJw{0HY`ow96a6o90@vC6KY3?(|Cb#9-^2&DcHTAwmSe)n;zbZncvXLQHo3z;F~{H7 z`kz?(Z|v{$-iN^R=5Op}pr=5+ke;>mJ#; z1N8~l`-GDg;0@3Nr~u^uZa?8SA$b%60ODxE8%X*$%{mJJXpIB_sFwdtWBCdIkVF6g zEzp0{{@o|<-+8_JM|FRX!khMl@q1bZ06aDU0LUf*03x%0lo7K3=Gk5lif9SX2 zZj#=(MSA19AHYE9?wdDn{ayQiH2404dqg+x+`WDC)au_-P?EW-G4wtDA)K~aqrwBkO*S$ z-n{qV?-g{$tAY^)lcQK~ry%-XaCa0XBa9hq(WXSKsN=6W~Rb8hlCo!dP7Oya6fn6l$iT zq&T45qPrcJn;Sd##=ci3T-eYDz2BVdI@h3H+fh-R1)bo%4fO=%VH!r)1|@`N7Axuyh@f@oEF@Dck7|K<-6X|(xqof0*MySOuu7*b4+Ri z5pQwRpAgFiohKXdt}--9HeuD+k2opW{2mhM{(&<@4X6C68Jjo>G@gIV^u7Z}EEDVe zDbLK@+T?}Fp^IvA6ia%;a|&8X8$s|M((Y;-_^rN21y=?k56mdqg+rVNcBU$^vC3%v z)Jma_gQn`M)l2GBBU}wg186#;-X2l*S~@1uR?QyxxvfX5ovY3+;ba;T^G5Xivi)S@ z>gxRl1A|;E6nX+pxblZVRxsCq*F-osG}Q@Z#B60Nx~y0z*HHN=CzHZhig}p*%&Fzk z{FZ~)*dDrkc!lzhoB4T7?nDh$oFMW2LLynN3NCs7o|a*pMD>D~J^fqM9=%mgW04trq7Qxbz~fRoBz z+?Mjkh&IlYz=xycJUcYP8k|keXtOjCQ?Ka>imnE}Y3tlec%b|U$G9*=G$M6=pK7?v z2l!V{IRY5iVFc|$yM}FLwj=#f4d?o9#ch3*qS_5hHX_~}%_S-<3GO)#+tOxHqvbi< ztRs%TrWyL+R4RoLIsL@jmmdeM%L2`2pJxnsRMrbuuj$=6sJ6@Nl5{6?uHI6|6|&v6 zG`&H|-KylY=;!fUS9ese@;J|H<3zYSvwBkQJWICTIQQsFf|>k4&$m{;FyE5=yOvNA zzgFe7JwM6Byuy9pH6WUjboo)AKkxF_?|DvEzUxa2rK)WUBhLn|aPNCu!RB&h=$ZL2 zT{l!)7c5L{`q#JWcHq0}{h*N`<(5;fL7l`7U8w2U+Ko3l2FapGnQ7)mpERboe#eK^j@ zhMS?9|Kv5*k#MoIVgO9d#Y>!QoBa-PeH63*mcN>Yt}W54Y4Q3fQ&xTE3`$ z=4{c*=NkYP*^5xDjIgXt(_I;=cR5GXR34Iix~*9vsLU13W{n|lD9Dp|&7k+Glmo0S zbX0oW2s^$?$gD%(>CR!E}SE{o+dU3=Yjlg4v*(5?9xN-u(@{_!qp>St;dSpO)Yl8yn4{%XkWi zTfe0*`{vIyy-|vK))f)%vaxL&@ojjs#h6bxWT+k3!9TQkl;i2<2L8Ul_!YVG2$cHL zjEVY=j7IkRHxGkeoMD4+8)|jLm{sLXGjzm71XSjkR=EP*`^~nVnkpskK<@ZF5a{AJ z{aA^6^?POZoo?m#vEPpmHfHJ(#TzP0?QJ`0G(NdDlzK-S>fGeZ&T1pc!>Td?oqkuZ z$s~-9$;4#h_MQ`Idj5E{XdPv)Te|g;!%9Wnsib4)%nZv4{_F&$Xl`25w{Nzn^g)MG zgw8LyRHdr9woe(1L?PH*M@%b&Vj>?)|9SgHf%bjxuC&S{;``?AoWV2z0BUgW<^OKl zzOjB4hNIx!2iJ&w?n?jK)|dXxDwGVaNPgAP<91p?WaYWQoSSoXM|qsi4DrILvb0X0 zIm$0^5o4=f>9B2@DhHh2QFUEBw zevO^dK3u9+wHG2~H?(x=Lm{ZV8T_O2Zrv_OK2&OH{Tm6>k^rJ4Gt!#~C=Z2iR3#iZ zCzQZJz;)h(3%nbbzCRZy&+f1FcM@z|@^bb+di6nUWnpRS@@#D84@P;>2OWCTJK;K5 zDV&L;1YCcsmD0fy^jD5r+B>P!T_u#XQjJP{@@u)vqon;~oCf|x=ZDUf^+<>o`sE2| zmino-!dS4dNXxL9d-j5Bk8cjvzEfTzjftqtWBg#~WrGUS(sG1tQ={SZ6T0AaQDqaL zrrx=hb7rOVpVI}pu|8Z8N##$wVyG(9d{JXi5k1&krM%~{obXqv*ruajO!H!Tb)ruzqr*ajwPWX5{DpObPjO$e;cw2(sc zMLxf^-wx&4sRHIQQjZ0}2YeI&d?sR#k%yfx`H$({dz3wN)NEj-WaIF#|%{ zYOqdWHy5eunUcw}!NvMH>1)9A_i1a@E0Uj@sHtzZrZZ~$m?@M8a%MiD)_XD3D(Frj zDcZ+`i!<7Ld)G{WT@FvS7TIEyiA;^DMKQ z+H~d0_jkt`_`vLE30m)D-FHXF9fMp{!TJr9RlaV@7|l34%xJ1BZl+SSXL+|j?_79J zO_rT%Y2dK&^{DMEjAHb&QLH%puNAY~Xi!$D=Fd^)UEC$Znu$K^gU7DksegdM%f9U* z5Sp5$@v}~ri>Z;$E&sdr4oX_=CGPFn9PPSB#YqbK8>PM*BjO}giEZu>-MW&7Ai9NN zS&tJ#g@izE-Pq_FM~Y=yxP@Ue4UysCw`rjcnfceF_a$d<-&Rbe;wV#B5X{f`{W-)^ zU#F6N26qi8f*d3a&PnJal&=9+GNW>98l0SBehlZ))G_;N`azum>5PGI6RxUCPf4!< zm3VIU@yrC@3!Q1*`d46poSbKX6$F;czj7YBhLC`^Zoy#Ph(QTx;sy*t3zJJG3pF_+ z5x%HQRG8`}`jQpkKY7<{8&nrO zb^^5;4$)>Pzw3hLM%wUe+8L~@5!W6e{R_^z3sX8Ys09rIS&XIp+~;=73blu^?m3c9 z({lR}7rsxtV$@4EIx63w{Lsw!$;_H7C}XXqCFZOXwdfS^@#m@ro-LHr=Y1Z_1aI=O zg+heKJ5OwXr8}#7-EO8k>JTWA!}d`vgyEn7zoE960J>B`Tm!BFxe)-@hFil{{IhW# z;J1@LIib#xN|h?y{0?oa_FGlyF(^|u#%Iadxf$>0B?V$sfPx(Gm55}7VDep6kb57)0O%DC|5Jh6|GQmUcE$w05}f`8(RzOL%YCb9j=or;o=FC%5(d zDfo)bpm@(t>~O{#@@+nRZw;+dwFgo0u*6Ei;eX8Lyhh{Q4Gy@h)Jv6Cyj|=p5%)^; zXv_<&=}Z}!iYLamS&)jcQcq&)r<13N*v3yV3wJo4LCbIo4OfkO4`KXeKI4Ko#JYTml5Gz{_Z}I+$u#qT`lw=l*@vTF zu=CddOmSG@H2@FmOuPnodVFVah0G!t6kp$ z;hi`W_M1-%b>>`%nI4hb_c7r~Evh!qIrqMzPLl(X>MTMo?XLlw53T|G{?~wd2KWhn zh>okIB$Vd-(HYJ>s!bO`-@0CDM3fgL(^Y&Yqw(F~%eM9*&hkMPDX04t#xQMTi0!W^ zrBPtoYk5=@0zjj1K1=Tk8-#xKBn}}Ahlyrz7)~s(7YV*P!Q;e&C%kjm9|g7**kP>tzt$STwRwRmO~$e`eV7I1Y(p6&s-ujSy4wnL;Y?PZwt90v?`ScVhv;lD4Psiuq{5I;=oH4V?v>fjCaErK5&qnmu*}+G zecqB3pPc9wK2NeJt(ZV9V|!8=)8aEl7~PEyymPvDrX(no6p{wSe7g!lX*~i!QTVL( zC>xDl^vX3L5f#{t2>jvyNxMpXqd@pDmb=SPSY(IIniDJSqm?MuvY`jcmQ8~m=yEnx zHQQkV7G&(vv?i#vMI=bl^u4C{WZ2}0kY>@CX9qO@JyD2q+HPV#Hg$?J-pfBHZ=#G$ zj_C(FbKLT6Zch9uWlpghkC_oW0*xc~k`bNOZ5EAtwH$YgFqxz9ul}`$x*dQ{0JS>z zcrOSgitmqo)2f8)`dbtn@K;rtMuUfjB^<-|Zy6gRm{)l@BA%DLi40i#&E2mlZX=ke zT^&d!{RwclzK0*H9cnDi{p$1Z%ewM=$=sODTKC%4u(lKOxV1}_yqv7<$il&ZNtn;z z%vrEcA2a>Smgni_?T%AyT9dyWn>mB+AVzX!J2`v0JH{K*K%a8jTHUCH)V;OF#VS3& za%9u^F2e{f=}=piql39OE2e2iX~P0dE_a`>cu&3tJSo+ldUfMzs9Od$Q@3vWK)MdD z3Kr3x7ww?y)XvTrEf8Tr(SD?GP;T4hHP)()d8zGMqFWFxxpLr_Y?@U;C&UCZ|3)&m zp(UpYIm@ks;K7!~MS*<*)1HfQ{JypOn46u?9EyOI2%^(;7v~?jS1iR8>m@%=4o$1h zrQ?(21wI%u`vmjMn;!giqfR05E3q#-Y9{UEQ4h}Bw+V`6)p69qeoOz{FxG=kX&4#y zyQp~t#m*~VBkcdXvqWrb}dF_2;lc_`JC>kkZKKp|fNTe+Ko z6W$jvi3w02xb+SfPTPz3L(2>-QO{o3C|g1LOR*F0Evlc|x_(au4BbuNS&aVdO+z{x zm1s7WpC=ZkN+$1%saEB+GKiUF8q&d}t^su$_@ZpZ1t#k+1-kEqH{FUiqlM=U;hP5w zB#poJgBrDl=ls-TrT5xV#I}2ql|jpnc7viBwI3_B?09zgd<4{U9+4IQSl|+wp(W<< zexyxRFl$&QQ)37Ws<9b9_O($HlM=&4jNzWsH_o%+pQ}o0D5;I*xKl!$QbX)g^|y`$ z?J%1XHgY;4Ikrt$F8xWdWrY~&+e(b4jl!8Vs1Bz!Z)aM{ zSs3MB8O&^DPFTsvk#&=_=Hjmbxcbg(09ZwifdNbK2#f`$*7Di5{fbh2DZcq$yyJIt zxfLf{CMSOs>1pS^k3=)UO>6tjzM<1fg*_J2N-Ym9?zAY5z@H4_gokH13u8FGrqT}W z28-O5aciC<^0Woqng<8`OTYd*aO;5w{rlcf8H0$TAQFyZSK+;#-!;Vwh92MI%QlVI zDiSh$&A+kz*pOUPsmFPpKo)<+GzHURuFPZoJrmc3%|!pYW$Gb?DceKVU3$y}fM)F$ z@=1&;jnU1(aQdabERkA}dfzD?`T`5(ZVlg~Yg9aF@HFG&lwO(-JY1^SB#;}4`2_Rp0xuhk2Qh3*T);s2AO?us z3}p32g0Cw4xn`L{Y@+>dd0jl{{oCPK`K-P&=-NEXa*Oj~E+=v0C+##Xa9n8lz>))cmp^%8e}3%fJZ#4XTng6zNfcPW@@I9=pOW6owKMf=f3R07)av&f2Sz)o zEoB`0W(nymn@UD~wqJhGRMeU$WlQmUrJq<<0@r>Gcy+h9C9XBBzo%KYdY18&Kg{sB zMojT|v*I-0;Pf8&j8;cIreK!4e!);uX2r5fL&qK{xt$8*DRHIhYomFntnKENK$mS# z^jPt0X6hCHk#ku~NIaR-i9OP&$_8Rw=M+>6Z&X*9dGO`@`SOb@iV=g{kS&+gs)rNF zJACSDBie71-HkDA{yYmoXk^P_>)-_pc>)C^j>Rt@AdzR3FhV+nL80fE3k(MEZsV20 zxXxQC>BoVwaPCJxUl%LNEB{P`rGSK(RdVg920oAp7zi!sWWMgyOWKjh<;YTZr|J4L zb?VS(xtxT(5i00;E6`(7yQTVakB%a!+s*}+0rYWJBF7qjJILq+n)I?|*Ujs>u+w9l z)L3W)>_=YbEU>%JjkwT+y$86s(7@=26T0Wm=^7(bJ(`YlI$ZW+6J|dse~M)d6Q&b8xUWNB}0ewvxl1ZeEU& zqQ<|snDfudy}1TlDzI8NJ3YJP&sTf1`qpz9FBZPvCZjK&p&_*+*{GSo8W`26(6H!% z4o%NliZ-!Km(*BXZ><%#{%9l%cpTu9%Mg}CaNFrm$c875pa%1U{=$Y?a0)FWjh{z) zwZ+C(;GP`rd1Y9K{AgGuK zW1f)DcPJ>#bPaHZeDVYtxcbYZNM>?Yj7xQI2PFSDL)W+F)5RjZd zNJnsAovfSlaspS$ZY-(750&fnnec%^!=LQ3tGizVip4H@=rvJtNwQz2(>yfn{Ko9h zgHSC+<%&}X&)8S1S^7JBCXw2_haX$cqem9#hg7F`GuyB)3dP%*$x!+BH|C`qvV3zuoyDqGHkEhR4@7socmt&hFB@k>NqsD@t<;-FdE za<#d(n`d2K6!ujz{#`&>ejoWB$GmzhCSWOG{+V$~htQ(KQ4u|9+DAtzE*T*CnCWX2 zc*@yyy}3*dNuQTxvmUw<;t_LUWLIk0_#p4*rpDm6{pS1yi>6R)ahJ;ga$bbjHS&;W;?Ky+Qh6j(MJ~Ny!nW;VOG4y52k!@8Y@JE2VbnVi)Pg z{hHlC{jfdG8HO&{+6naAfA?YffAs&-0}QFCRn87=?^2l5^zj-zklI?Uy|>TL%*c>%qX^T5;fF|E@J+qQfvb&ZapX8Ew1r}>H1>hBOOfo zy;}Ws^-`xYQ>2@j(%(#QQB+dgY<-|zqF=!5;%i#tE>bOhC+IF+rK=6&Z>B7^RO5?_ zv+~llMgua&r;M^v6W~&@P*vJ*r760yWow)K!+h(n0D#FS`g`n%)kHXC7hb2}LW(>K zrw^iz&2yl*J!QI4?&Jp=2Wjh7T?4WRjzwYMw5HXt+DPZ`&x*Z+azTdBwe(__rFl-+ zz7YyJg)gADDF43c*Tw}d2*EVWbW?t=a21#-H$L+5J9P`xtC!^Bi>8VjTpQy1Ys{VXkv7Gbl%-JQG2#a~ZEz~?tN6%|mI{*MR&d7XWZdDvx&XtT zgGP`ib>jfES@=QQ)pCebrGrK^IL|=vV9b%<-|^kEzqZvS9m!T8$6aPEbFkY-fpzVE)FEL- zMo~kdX5ig6ey|;HoZ7oG2PbP!lA&c&zMiE67w%LAnRuU$-LQf74tV9XU?4vgc5@G1 zR}gh_;O5Cy1Dy|hJIULlljgS=A80Uj{!EmL=w~SC{RnxyjSeY4h1=(rMN@_95lLTF zHJg4v*0@pOWuj6(cyEWfT1&p38sbx9-;%6WRoE$h0~)xIk6VQWS%!eTK@3;ASPa1f zgM^|8PS;cF0T@UbV!0m{7QP_@s=Nkt|LQ&uEFE+_ba3i8aS02XaGyE3zi1;gzu06h zPfFtSq}HOfPK#}P+nIzYbx^l{>LQ0L57__4BwD zuduVriM9=5acf*}9-%4^O5H?lf0@83C>^t%MMd`6Mr8HeZWl2Z(H78tmWXF3f76sE zqv3O!A=nZua}BTVF*tO-vDDlOfS?;c+@VG<(G3&KX?^SZ8}MLN|OCdelI?Z zuHrK39XYX-;iP)&4*Hu_2NubF5b?=AtQmCr%m z$n&1NYdA3sC0GUn!}xFIVqj|oD^|JdWNp^uiY5lkgm9 z(M-LQ#95a)@EhN>x*JvT=6 zJ#=B0^3TA?8s1&TTxK~6Qnoqrd3#-JkV0smONH|_Ko?xbUhdn8Z>_$7P4T;$;E*OQ z(3PWkh!ngW&&#Dcli}~dvaZn2N<+h=Q2T56oA#e@Jv_A?X8;RnTN)p0LUr|FTwP9I z07#^jV{s661-V?i20$-v)WW7TjF-D}{(vhyI%<|%a^51x>!zmj!%p2D_`u?l8Uwv*5bjLOyH$0@s(8ALr$nY5JD(`8?!kvW?+kfed51gm^#1Pq zd7+b$3DP{{DZbl;&&I|$O3F>zKxvd29rEA7ApvvJJ39y2UPNyFz%0mDKa-)=^=kk- z1>b=D$WL{nj1N<+54eW5LB#M%9SDWk{m@lBUKabwk(|H4sPOFZ?wkxO+^P)cF=P3D z(ZSW*-M)rI?2){@Wr;#kc4mm3ovp2_c`kH8xDPSl&vR%8Mvd?k#N6ID`FR1~+dG6M zCWxJiz9CF1!5b?C|Lw-g77heTT*0X@_{#=EH^BSr{*eR?uJ4hRyMW*}>tso?LUq63 zo=9)%es57^msx7icczqi7=Xfu%Q)mra8qBlbEEUa7;y=L+ksM%Y+z2SidpXIxN(vv zWfp43+ors?jjA_%f2R}tzkvPa!H{Yf~NIk~h3A#5edZFWsA)7JnT1R>w%hkAw zYkAwX@KtrDkHmSYKcmesp_sKeXe=e+Zs0SK&G2V@ndaIU@afb8L%!zy;L$`GNyl`w zJ!N{&@KL9oCoEwg_rl8X%)UMM4_Vj}(#~is`?ly{K=l-tFYlewh>4lh{;Utq{{D$e zk|E0cI~j&*1uI!2Y+?vu&3j3qu zHLvgGyz99((9%yIaVBU1tY6?(2pTkdz6$g$`{R{-5ovRKK3J87oQ1As_#s`vp`m{@ zj5o&*Y?E$(r1yF!$}`r{6v4E2Qqe4BXOTwk@uWCct8cDTBPw+i155b15rUAq0__ua z!0_P-Ik|{I#Ky+f87%l@1C5Ya_r`;6nS%=XTl@yYUY+5*7lSl^4mx1pBUWlngzCn* z2Suw|+|rF`O;bbPCRy#wHuaJJ=6RewsxIlz(e-+BWwa*@lY&@NL;wO6<{-ug6^Ply zx%{g$zni0uEo)#*IPs}=<5*N|l6`Y5ZM^eMwLa^gW6cffHOx=Y^uGJI*VP%ROk!!f z8rxGEqV5REh)1?Q^`PNlnRY|$POnHDxXn!teyO#O2^(2+p<=D>{qDr=d zeD&XJBOSkrJW^*(Z#jI2B}8C2{ud+3zY8X{e!i89yCkS3E$sZyy}$`%7eY30W2Fmm zvJP_pi%Dr?BcF2YhzxTSdjRE{L^P{h14@=TDfwU9iRAct>70(QpUL{Q3OG9mJUx$F zYxbS5Tlxy2k$4@JdYqy86F-Lt3J6*=?^@-`|9QHWU-so^-5HRZt=WK=?hWf+=NHmPRlqLeSg zrIO~BcF9XS^kI5Zbi~9;qodPLA{sj^t~leLpn)aCMdcIdkGr~cz2+zeGmr$qV@!!< z3C`D`>Ju1CFoh=BjFoL8&8O4lgT=#TK!V=15i5KBJbI^Ei(NjOE7lO8&vKiEKj(1_ zN5Obgfit}~xjOxdbZ^cz;J0wfO5VC0#v2PM@WwbabVax8cvZzjT?rC(Zafna|S!k)}>j*-l!7B&aslmHFg=iH@8w>Igv|rYe*-4 z$moHQKKt{WW{r(&I(pTxxTcvcaX!EH+qV~4^AY|$KMJ3IP8t!^wWjQ*Rznyu&;`FE zxq)$kL$@C)^P`_6PD|tJ=Fi2}EnvxOut0m;btWAG0Qb%Y>aK{!gwYToU8K0l z641XNa=>ba8NKL5_y;dvUjvNsgzZ*D{uSf;`5C+u3YYLmObrPk%yta4h+sk#9w&iNs(6TGr2WL53R@uZ7{wRi7Z(PZ$ zoN!k~mLv5#!%p>CL5L#s&7oJH6Vf(L(d?Yh8qZ22c-5C8lH^a|<_Dwi_j95+HAif0ibr4il9bNNcTu5ymrYC* z9sv{#(bA*oxuixLed#7Y3zCB3V|&k^6Ezy2x7bS%H!4nB8kvk#!@JDdf6ii-A`8vb z#I4bHA3~TONSovouVg8-8buVyoh5e7z{=%H-Ir=rtOH3dH$qDSr_|iyrGG}P=z{&3 z>vVtF?XV(r=gv~=z}o#j2PL__2(qvNnhJokFJ@@CW6v@Zm?KsUS_%2Pi~mVtxO~;-1~xY@+s>vb7>Lm@IGCY zcALOpePC$Zqa(mAmVZq`R~+t1QDP5gP+f=sJsgA$Awh7exApTwK?$LQjA6le<|GIk z>;ORRDRByE?&-#_G#bnN0K+YHu&x+NU|!{H$dYVT3~t80+mMc<_w`uS(9YOAu)kA? zvL#-UbY*YHtA@jrUW}WWlhe7I)f=LxQ{BR*am&bUw~V$^cUu~=43}sv^1b$smSg-wMDQI z;_svRzsnSLGdtizzo7Gu|Oluum^J`3N}a>yE2JE<=8+|%e)mvdi#9#=JgP9(?9 zXu$c|>v%v+QlESk7P#)8_jh)NkeWEpgM|@b*Wd6jd$XUzq#J z0l$m|a+D6Z;3^>#5u?58>RFrZtCIel$JCnCj0Wz|1use@!ah8>+QgjKX5ZOwm|~Ca zdi)2f3Tm7~-y3mia`An}KAAI)03G91+?X>&S%sEMvZDj5ehS#JPx!`$-kB5F=CvKo zdWIC-aQhNd!yp>biR*^`uA{4RJv`Cj#I$QbEW=7xJ{+(1&$|zYiS5ei9ZXf?KuZii z2^KjaG}CauhvCX9{v7mKE#*lBI~k+89(fn1s-<@%VcV7R@9b~Xd)x(+=tV{nc458$ zdiVWzV0=xQnolXXd&0~kVH6tnGdYac441iop6R5IFBu5&he4Gc42H4?iv?@es&VYv zA7`C)|Hxa=F&k$kJBiq_esqehD82L1j+JwcrJamry!z5V8Lozq52PFq+rT~y;w_sl z8=1dSu6Lgt?SMV6jtUfc^<}m6_)?RZl9DL*_I6WO#F%y}c%JZa@z>6IevfR|fs)vG zw*eHF<)_yf$S|^O@x9|&>~0tFWhm#AQKng`Oui{E`14{hGw|`doZ9YmgG2Q!@x^_~ zH{Vqq+kgy4{3EQ^$V$tg0cUkKx)z+F!=ac=m?V*e6-(|C5TYDrGB$tvo z^L^e(mL9SqGvftO=XIuKW%~^eS^nI8Ul$D;%}z1jp$EqBtPxtpl1;~3O3&>nPDMTY zsw>wtavp00vX7E2-xG<3h`vpw&SXe2!fmVDU=AV?4bizVtZXi3m3xg2DXQ>N+hSrA zR1f}ec7Z=|;`B9Dq=2RZWfW)o^p{fDTHpIsgj8S16^?KrjC>4X=p!N0H>Swnv{^^L z>zg^M_Sjv4W()^*!CYM|*wJHHml2!@bxT!{cAo2^o2)+muUgFSQWF~OJ%n!3Fo~{u zLdY^>VPk~P$dGp-{+)Ge?NiPMWW`uMb9t9ucgVa?G=9*sgb;Y`TtNCglq-6bX_L8G zU~2t9wcK`r^BO=>ftII<5L<&1;+`RNxTqBhE0#dZST|5uF{bfW**fdf6lL`-qe0{% zjRLCljYZRlc3I(mjqRMaMQ0|*NNTO@5=$;m&N`ox0cUq7tv+4c1g%Z|lCpCjtIs$p zIJY~UxO_(HeXrKi#!!Q0(mJDL$2bcEN6|t}T8tpyTDd=WvQmN#ik#F=JbU^6*CmnB zPoXQPTl?*#vZ=eA_$R?nHO1rfF#*sW*qqBPNEI2khUi)Ul?*%*GbG-8pJIkjMF^w|`peKZ=9$p@QuZN+q zE0w#Kou^!%h#wP7Yhj$9#+iuOHNatG>Pr}1d#GxNOD()v8acC&SdotrQ~SxQr}VjF zwQ#L9=s3Z7%Kftv8^L;xch^&zcJ1(`Q6_DQ8Q^BG&hU(dEZ@zWmCeNumEaV=If~UK z?ju-^EJ%w&M;rD}|ttE`(_6o;^8KxpaPjxDp}4!Q%4&7+F)4OeR0OF1h?@VxP> z9olQ6mN%7D^pxX#;)I^R8zg`Hsz3|vYk#+~wJSfkBr>;0PH?>^Ghfr~H_0tb__&3$ z;Th3gi@VJ@?ZRxA?wOiM8HlRij(kW~Z(iGkhKQQ4rk&(?k-_Czx?oac%j;B$XZ+0D zG=(SOyQ7UEQ@tT3ou%3-lEf>u$?enSDTlK_Rx*O>CGGMDZh$|aGr@#lQx7ld_yF#%0Nmf^FX^N3~3ZJ&+?Uc$4sc(;#M3sHU^p(*IR>>rxYy zsf;WW#>b6(V$(Oa`t{(7o7;<|t0rzU*7Ty=_GLN!Vz4K%@<3!$(G={dy`ug#;Qp?e z)K}<=^ZC+AbrxW}2U^SR2?~H;_#C2P-c}4}8Eh!RA5M_jDpQ>yHJBeBsy68!=3PVSN%0N+ z81IM_4-sM`0XF6@9ZjjblW(S7aDN1)@_+OX^9~KO^X3|q&9>SS2775MYGo10P3foV)a^Oyanl) z5*+SK(1%=V1>K3cyK3yR{)wgIk#v3?Zy zKDVpXqzMHXg!|4gK|o~iNsGn^1NO(~q>4cHz=@>Q-9_1N0vg*4Dqq%ML{{Pl_VCX2 z_g(ygIby-Qu89DZCroQoKJ%?96jnND?(!=|5$7Q@XCBGsFvZF(6-tnx zj86(uC7mhjmj}gtCX^nmXSz?uxWC_q$F8TX!nG#+VTASCMgp>nit^8!LY1?Hg_Q^( zGYYJmsU%1C=99qV^FNW72Aq%AlU4;Zt-_IcFm?BkkW2^-l^%yr+;rn}^@)l(QwmA) z+J__tB@6HNn#&72eHvj(@gF|eF_8DyTDDoNBnUeuKJ)3AAm7X|fQbznj0;t=lytbK z%9qUSdLS+8*GnqrjHyw16F)Y7V)9z#J|)^74GL_GiDS~TOpr%-$qha^gwFZRaC#`{ z!1&^_4RtC)iD0JLls!_2x}H)qu(i2kzQm zLk*4}l`ZTH;X#F;1R|n{Gao|FzHeSFgX$|0Q~u&?8_L%JM-plewzcGDkTv7%VkY{Rww%z}6xF7qJ$}mZ_RPytQQ6TU2&Zg6 zhg_KRlKC4B@=E!0PMBzH5DHs-$IeA79R`K*h>E<}TI7@x-#-%DW!-snq!g9|iTQO6 zU^b_-neG8sS-EC{73^Do(zgMRL<_gsW&`;-Nz>NDA%M4Rpp4K=!+s2_}Jq)#(0 zAS7A=1y)6%c$}EhY-l6;)>$4O2^)&P%cGgo-q@DBQ|)L=f-UF=l1R%k$X>*sbfP-8 zIb^D{)Xm6z>f~5=Ul!O@o%82&&fYD()Hgh(Ii50@zUbywxBAw%0D#xOEY*L5JMN(- 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

    bVFna*16Z>aWx*}+p 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%0A2L^mf_gcjoYgSPEq6uyYx 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/android-screenshot-connection-error-warning.jpg b/docs/static/img/android-screenshot-connection-error-warning.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a019046f277118fc769b92298599b36b9b25e5a9 GIT binary patch literal 104543 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/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�SlAn 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 b^4-6heqR~}JpO+u<$s;v{}%&ye^dW2IplL& literal 0 HcmV?d00001 diff --git a/docs/static/img/android-screenshot-language-chinese.jpg b/docs/static/img/android-screenshot-language-chinese.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f31cc9cc8c4f9881d239a0906390197f06bfc694 GIT binary patch literal 114879 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

    Ewer`KRsnPG%hkZq0OW(o!ZZ2 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 a-Noj7i#JGNAm=CY9|QQGdhpk;$^QdI=92CJ literal 0 HcmV?d00001 diff --git a/docs/static/img/android-screenshot-language-german.jpg b/docs/static/img/android-screenshot-language-german.jpg new file mode 100644 index 0000000000000000000000000000000000000000..254f49997916104ef10d46d2a6bc1481a4a096f2 GIT binary patch literal 150738 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 TOsgZSIb2K@n9<%Run@-Ts z3J9^EPKD;S>x!DNQjtMh08kbr@-nz_a8Q%Akyh$cLB&w|B1O8T!KFf>nInByO&U)_TpxlF=i)Um)0xN6e}IO8b+Kd_;PZZ6@4!K{e0oa?b4}6q>&Up;82k6K%)s zVjcc}6h@0tW$)`F0M4|IF0%Drxs~d9e3F{CJIR}GU8ZazApY}e)0g^R6*&A^^eU>4 zaUrwC_TO$7(l;tugp<=ER!So3@D}Kezb`w2EY6m0>f|_QpDN@{WuOkm&)b27R_PS+ z&0<0(Ay5NoLSLkyqkO+ekzP?@JbhyO_u6x&$D?Y3RXndpocAvn9PzbHSh$=2X(1%d zt^Wr_{)KCKc1yqx0Wy^>xp4*~hDSc#%L02T?+UrDBR-l{lRfp(;FNNEM9LvY)%pVA zA0rtnr1ndxQvG5dW_m(RfS376#w4 zODDSX@nIYz4t^uesn_9Vhbp77H5=tDg-0@A*>bL+9G*-|O|hH;rLi8v5?|eOz^mC~ zwXF>31R8|x#@}~x{8JrCg!XPZWA$9#KE5_5H*>V^WA6E-y-fhwb|qx-I4>eo-4mF- zQLu3^&RuLgk~Q({LQ+a-+k#|JxvWbPSzSt6WTYGtK*!&iDXw3w086X`80&x>Z2C2opGu|_Q|QnPU>V< z*4UDN+6WEgtd+wiIM-~6hkE_9C6nQrR}1apY{k7wAT%=_YF|B1AMEPt!97Z6ZJuB? z%`;PFY?xe_2R(`@g}dsBW= zuv1l+zMDu@OZtVG!Yo-A{NdR8I#ZH#zh-%nZ%ATI4+j#E z-reOpLI?+atIfYqr(&+=WzraX!H=4-Jh9W_-~lPYd}kDmu-PJ%0FF7D56wa|8utAF^c`!65g#_hmC5N(;{3KNu-#YG zO3-y#V23i6#aQGKE_+e_OzWw1FF@nr+XN%oT@2ftt;5>63l&@)e|UEymR`CPgRs+M4M?^GI&IMY#3=0nC=x8HCkAXUP(v&zv4 zba#OYgwZZ(tQL|`^%*9OIif-)!T%vsZLeOcR}i^cHU&liBDV0u0)UDHK;`i8&wZW8 z|Lu18zZ4JuvxkZP4dI2vy0b}iv^$X$oyDxM8i#Z9cqM9ya-E6?>?xgAS5wEM2xLfs zzj_3rGD<9)U1)AHj><)@b(VNM2%jh};VGlBmEc6!%KXzG0aP%g2!kV_8hfSh{C zYp%A|TdxlOyAFPFUL$ALbOA~B&nepT{UY9E;jFCAU7;+E40lyJl%iu8cw(9>j zp!%w%7eHr2Q|V-~#Xc3M?{UeuWO@54M)EW3Y`O)Cj)fL*NqE?x&cnsi%_1RD0K9ST`5@!0g1Q`0}GDHZ&zNLCcs{1v&y#W4>h=50%{sz6>w)B%&> z?a8;1$D;HQ_t>jBXbFqp7F{McQ#iS-qI|`!z+n!ELqNxKb9oMT*WgO*XAvj|=wHVl zVvtnT{Z;>J?C~FzxW@&9L&)Sq0X1%k=^kSzwXU9d<)w@(SE2Sw52WR$QK3-c*c#PBd$7Dl*qegfwhh+N*7zk>RG)o)Dcqo>nE&jQi z)uxKio)Q)yTH~el|GJVSnDKsV-_Vl`8^()#5<_UqA;$1#aimgWUq?jbZs-`a#Q->? zbRLuCM_WAw@O_)>!n^v>r)5Nke?GbfJom#%P+-lF)z0ny%*+aOaxu%J`9!{zV4YO~ zr8trc&-()(pAfP-FELaT*Z5vke^yxD-qgK|5`6h)KAp@BS;k#K;jiOQagbMsAi@W+ zQ|hKx|1FGsD-Qia_l&yH+G!pST5{a)!<_L$l|&b>!@wD{N>4g4G-5HY3be#QOMJv@@2hkTf7kR%VeZREcb7W-@ym zn<6|L;mdR>Z~{ zGf`2<3=1l!>O69N^`GsLK5LdBZ5+kLvuzY=L(;rloH&CGY-+hri49HgFc>dX-VH1~ z6ia5CEZZpnVcX124s<&jQg8b*`hDH$cbMh%zM@^lcIaT4{@5c<)tqA9Nc#E+`cyg( zK_!Ke8ct!i{1UQqYHDklSTfKs!KP}H?U=w-W|3PZpJDDO_Ceut%Zj&dB;`JJu{mp% zpmD(w*{GdkY4`~Fa0(YnpJC;!A#fXN8R{2-DoeWi`p&8WsB?>wyQ2tg38TfG<43OQ zKZ$j~_34twj=A z@T&d0(0cSW;i=c>p*G-S`{DWXm-wOfGRX1sNAvED?@!Rz-*2bifDzB_ywsWzSP3M5(FtIeCLGf?tNxl zGr+P>bwuCwc`?7rdS8>mVjc^J!iej#p^w!~uK^JEr_r#vJJLv=EBbess5YCON#Qc~ zCjQ)$h@u1oIzK3YmGBCs;9ZXZNExgp(97T`ew`M7KkFNlkDG3$=W(Dc)WEp!$)@)b7QZ{ zaQ!?^$0MMBvL)EZ_N`73!Cg!adzck8X+42AcCh--*Ms{at$~+P0HC3n8^PA8N@sbo zliY>e+RDM4JMz6`Z?ut$O1Vhr#TaY;^!aD^8g7Y>(c_@0FZhwIJk)pdu1Qi%Ep2vh z-?}1+r@|Cc*obLC$pk~NDK~Jn)7YXSxOthzDk7K|!6yyT>0$C~_-gYt;S*Z)F)!-x z{qX#7dVV#U~@}ptsDP-_n_(F?4Zo61!p-U#8m%2zfDa7y= zt3>sPGieZQbD~5&32*6?Dv=MTchOckl0ntT!9&Gj?rhJ&<*JHp)o+EmMsN+@p{XWS zmt!k($L_Hqubo^31@WR494f9?Sh{77L^KbBH!hVzb3v8|;_1u+jdk)8g+95N5KDSt zdqIh_3sI}Ni=5-b#`|Qx)jz`SJ=OPrn!+(p|M9&1y!xCQN_u6lyc=6(L)MUZa3^$|)`)2*?51)GX8);4c2LwU=a`sPhZQ#+q5l7Zi^k-cG2U;fN1 zHI~rKloB*FN_`;3#tgyWlOeW|`mmiAUq@$}I)_kwNuA{`14Rook;pv16yL66< zS>~4Q({ybv+aznm{o%%Gex0-whHHhBoil@c*-c{jI9xPkVr}NN5y89oydnLg)JJId zUr%u7?tcb0Jl^VfsW9_uCpID~lV`6dt#`(yMpUjg4G9jIxd$kBrj-IX@i_n+1~w@b zG&4^!^z6UTl7lI}xOvDQb4+lXNpok4@Qxk0;?dc1=S$t!l%S(=oZz64lI7>=kWnPX zwAOYqrd4Z^P^3$kDYEgpCboK=F~yOtn= zp3jh&8cIq|#X9mIlvU@L)XYjFmm1pHk8jCU#=06YHvbI#o!;VJ>lgA&XO>w_Zj8Ui zD=;F`!>){%pX01N=O%BlG!dMR>)2^d8AIgqie!Ndi~GISLp0EgIY`2?fCH;IyutSU z$e+ChDMYstqGEqTc1LAhKh!d?uRAWWNcnc=3@7q_JID)>BGM}&f;+#<25?y4BktbM9@5~k_4U&*d z;Tkp@YHwO8bd@NHU;tw#+BEYN!ci{-lc{B&iWV5B3degfcv)kLb{RN~;3VRn0ncva;HI^KgOxrvPUXPb>ro={A5Q_u7YP$LjJU=1uhc7Eb$i4F z$0AA~u?xJ-N?U5W)^5rq33zj53yU9>nnTXGkcqK(gZ!gX0aeQ3;v?>0DWqX|R0@D0 zGm%u7|8lpggF1`IUf;_fCnLAhM8WmXQyE>#}%p9_2DGu?n!D`qT#lfm3RjJ3p?2j{*OyQ-04foR5to)pDb*}YzD@ZvjJ#@WYgpWye=3wfE? z>|Tj0>1zwEme=9~(`4xE%Gj1Skp2vSf$F=D{utYuXx&?}vu zgqqVn@J(4IDfWL(EFlf!sOjZSc3JI2s;d2}FbV5hr6hez7MLx5d^IXXogC4XcR}x_ z6C#vf1YEiDsht!9JFWLp)3XTiz_fTR$FG??!nr^pQ=vy;9e3eEL;@VNkREl))?L}I zh|E_MQU&qz4oJ0sPAm4zFJSpKHRn#9O@>MhFE7{RcT({_XWL>uAEV&pgHw-9xKAD( zgVmo3>3!w)3qP?*(xOdf^}QyCcwXu&&pX>I7s%u^r0JG^jNy4iO{kX1B}p~Ok~!MF zyj0S)+VJOj1`Q;4(KwDw=W@Q*_HcDTjNf0=Y+IOuGYh|= z$9B`-ESJnTKa{#ZX3trjZ*ItYs;6Y@D+I3(QvBe1Q9ZLY(%*$Bv8q-_%RbH2%W?~h zw(h!DwT-?k4D7!;v9Cy$dJ9_z@Rj02Mtt(|XP`t8RWfk#gAgVJRQ6%=YOO6;Tw=IIpdZ>I?zKBNaZN9CWKHOb%a-|hj=2i#Vr z2{GtIql_!s5TWgt)gC(k+R|~YA#Gu644W3xU4O?7Pv_!gl*Tdb{sBub(itl}g1y#D zfW#%aJp33z&8U+hxix86s$(eXSx@Fw>+=>welnitb9ee#)VzE14;1~1*10>PPc^h? zink*&)tNZ$vRk6RhmubqBIu~uSLXk7cODPdpT1XoQ0y3_NZ8q?)(P8kwhqsVN`r+w|8XpGZ3Gp{Ke)}C5{u?`8WPjh>U*8c6x}v!zrSb zb~=S1hS*09N5B`cfIW( z{jC)J)*-I7FobIwwoB%vtr!PTN;h6WfvR35!?m2%|LI{&tbcMIf4m(B2Pr?y0cU2f z@n9&RD4^gAfis^NS$uAV0PbEJq*Li!qV)@!>ABpZG6X^4IAxVNY--;u8XP~-gA=Jp z9Jk%|>0P;K%m5y$wTR8{SBiL|U`Zx=VeJjTu~J9z7e-?lpJ$ z2wTyikO1phtsoY)y-Jgo9S5@g%PD{gigO(~MPnCs=DC+mF4l+GEAO(G)%*#b;AvJx z30jvUk+wEc#nvWvxrgggiVCye|Iyu72DKGMe+DTI#T`m;Ei^@nLy!gvg<^%!;vOJq z3j}ux!Ci_Jhu~HqxVvi`TBLX>#agVJo!Qx$o!$S;?#}Lh*iY}ndo%aVefOPv?m0iv zvOhkL_|CLZro-IiX_)XVdc{Ni5VVrZ#ueV$)$)WnvNydvu}~FiLZq}!2Z6|bUm}BE z;YpcN;ZiDeG|Aa;XVC)&gIP)yY;^)}7l~`n&2Xnp!p(}a>pm5fkP3)Ywui2J-e*)E z3?0mBgTKOVdf3Wa=96gUS_1^cP~D*r$fa=?(dmus!#a7%Yv6n%*L{)M$K`W^$o)mS zP)*I9=(m&^pBI6MrOB!nW<75z#NUp#>kV?nXJ!!#urY@h2tu4}t`IKLpOxd^>+c?- z10MzL-z&oJn6|(a_kCCu<{ZC`ThOjo>>eoCid20MBYW~>l<#=wo{sqodp_IMg2KnQ z^^*bGD|9YtzTSRDw25cdbl{aoif3o~G{v?`o4>Y+@%QaCaz1KUu%tA077ki{SKucu zPvYf(h1Y!HXFeVJROARP`QA&@*leq}On3OH%2Jcq)zdwq-^BVwE+us>)JT)3GKX9* zu|#GrbVRH!?>+-O*%}v~;(E4=x8IL8$#*}&ZdpnHb=&eetG)Vj1HbnVdeq|E$NMCX z^FJg!{LkNe{l1EO?d==CcNcP-ghD}KluJN$aLA-I6b z*3u7uR$uNe4XU+anZ6N};`Bavq#4DVd2{p0A6`<;M=TU;DpVCpbmkXt*GN4_FLc7NW4qFMVh@p4F@ea`Ozq%ju8rhA8BcDQ|aRRn|gkE znQcrZ*Y<@8Z^5D7)$Bq|;AH+z$I6?|*RnskcM$fQa+lp zUUjDGz~!JKE}<7*nJk-lSUM07oy8}4x%9C>!uftD_SGEod_je@OY%G?qI4OPrnz=4 z?b$l0AJO}ZlJF@TYyA8i{i{$V=aIUg5$ryO{z zFU_$SK50GyPYQit*7X8ibXBco8DwWv*r1AmulZb3M!>(2cjISFEXh2cgNb{cS!Ajy zw;dJyw){Atg{mcvnuQ2M5tOHWM9KkrX+2?zsW2hl8?t%qsx{f8***%%9U1pWuIog9 zG5$1W7}2|0%-6d(rJNHL4_!X5wpxsDYk= zyR2CB+J`Z<*QLsyMUb_4|8Jw>sBN(=(l=gp{{Wd%{!Sfj*4;T<({#<|PS4E?(^bCW z1`LCYmuUj^wR=Zn_$<|At=>A&X6_-tm-hA&EcUi;o_L{`yk}oxh*Ic|vogd0b2yzR zU7@G=RD4Fs6zMX39OVAqGv?a77I*2bX{%?e8D2|GhCw_=lXzv78f-hWK={iq)mfaQ z*CmEK#CF}5`Vxam6wBvH?MoRBRw(x79H`S}xW6ZH2$jpBztJh~qZ z5>>NoCY82i;Mt~YJF=NNcnM>jNx{h3(E~Zc6_ml$#@T?gpI5xbm{@@*Q|0(PZCW2m zZ2Wb?LXpFqM~uBj7v#jpAi#$luPP3|c%%{osvJ3OxO}?vYQQamKRMA@-TA|q7bEHF z*gMv--7gnhB>|64pf&?jpd0PiJea9VoTCw1dGwSh>0_X9mWeAQS%R{ncOP*V!-XKV z@*1c#@(3sdXX4G;GDJIwDJ?dgFpo$)3^@wxU79tf3FLUsVyD3Vx+T`S7yVLzsdFwP zfuwD(&G~6jF4mJ8xoTlRx|wy< zF`J6}O%)DGc5@`#FZ_*M+jrGRnx=twZhCPi2;qF0GVP%;H z@RHg2h>*{s5De*)X?*8MxaQT!ryQXd9kA%}^tJ}d5>WBAX2I69CO9^&Zj*&isQRrN z^haF3_CT_DJ?9dH(mFClcJRtI$0LnwpM996`R1#9NRSF*Cb~>$%y!>OH&s={v8>Xm z68J_rS%b5YxiGo^IZNtWA)O3)V^@nn6+vk(PVQPQI3&RZ$XJtYy0GY_XE{mfMxxB; ze{jX%bs=Wuq9)5FpRD)*G~l1DSS8^n*3_TF6+|HMcqhU==grYlT&f+?yy=bNX63ix z82d_9tKJw{8tR4{=m>_v=a9t-3eD_EcSvSgsT8j2kBu_kSs}Ri??pb` zkX+>+eA)BbTIct&OFpFG5w6-@nHNV1!Gal|B1_gi2FG|0?Low6zBa*jILlrshIHf- zPh=#FTj+{o*CmWbIXfj*ogUzrkg1ef;c}BY&rc> zrTe0>z_FE4yYj;i59f3)b8WSf7qIpzZBH>**{113hN?(G<9?v@Q^jf4w3<~Q(qWvN zwRbrMcgi6a&z)C!OzTo3TIoT|R=4)^tE9d|#mx=96`4Tq@1>-6B6Gw40D^`WGzJgL zOPl-K_E!TAfl*D(xiec+(Mlb`D^NpSMfo70827=fsKGn*jxO>1`0}1>TzUM z=F-{T2O4qBeJzkFtGZiM(YapP5+85VkTU|)L=L#HjkU&H6damiy$;MgM&4#i{k*&NRn2 zl--bcSlTNIknD$%@+!gWwp{&6y6mr9o{#fWaBI-k?!~=Nr0tC-G(LGmP?c}hSJ24I z6>80uSyKN7KTDC`kTGT`A(TCzE_qB+hFUh4XkU-x66)#uPBhNlb!Q9E=+sE3<_)!!4=;Eb`gxv{LY??nAdK zLmMkg_&MhOlBxan#ces(V67D17f1lLgH&_Zwx-FaS!mIooKI)Vg>0(Cz9+wGmRj_z zQlHz!Y=yk7>R24E(|H{<43z88Rd%6g_jaw+{oH=gkIuLme;agq6dnm!Z1+{!Y&+DD{@6)s) z>tfg9e*y*_phi4qU8}W4sS5!8MH#%k+F>C> zPM%lgx+2$f3GZ~%FDS#~QOaiyQt8Tt@;SEw=#V_8)PiDqiDHh2-X~Q*Et$G1Yyd+*W@7FPn{`Av zWwy6Va2!mF?KoeS7=0Q~O*|{!H)LxvJAShD_^~nT4cxS;6?H^HLzF0zK5_k%N4A!0 z$|ZIc(loQ&yemCvB&NPB%d3ewPlqy^R%ldaL7l1%LnOKDbneqO& z)|P67iUt798&Xz9#?nU}S@=k$tZYU%KI)i^(Dtl9EZ#7ZFO>4|Te zfI*gcgn@d64X2i;f8LJiA=QZv(%oC5csh=foBHaVx^=AlD5vLOaYx8izOI_z&;9e+ z2l9sIkxn?PR3>>1?WWV0157PhV%}o#vHtW@Y63yCo9kkUp|e zLMTr#@ydSMbRD=)<5FB2N>zqQ{Sv(Wp!nSz$j6zCkTzK?piF(nQhyon935SjPu z@6eY>uj{B8gYnmLvbW@Uu#`n-5a9tE50`nHaZTgqDrejoE%F#ewD8G8pigt=Pt>-b zUTTvXTZtXSsICZHVHs39cfx#M2g;4jGV~(D{OGVGSaHYr*1}RxuH>B9BS2J&ipI{B zHrlV+Wc^(x2Zg<7^(TrObdt$j$hJJ?!UhZ-JcYql(1g)Tc>|DDbeojIIg8de7}jq2 z8~Drc#DudgM|uPd8Zby(<)ev!`8g8gJcL%GOCTm?u&*|mojhv46UwLB{gz^pD5Mq~q!!o16}h05%TvU_rJ=Oi)%Es&_AV#IUlq%=P<$O@cvV%GkOr zd!hk7EpBLJwa%N)p8QEon3mzipTExZBeh>Bv&VkU3nmH(fKpOhWC%8Woh}^rYrINo zf7oQZJa=BEa;!%wU?$eNaylG&6~v-v;RtaponQ|Ks;;0p7lzJ%090CYZ#qFHgJ1c% z+#Io7mC)78q=2^UFnh3h)w1oyJcDd6o$DKfY8swL@qV7}laG61AISw(PH|L!>M?lG zx0WQ|r|QO#BlZ_ZsXlAcl<{V?wEQGTnK>!6EWlRQ z08TOhfmwg~skH@19gYnJ z$WKh~(Qwn{bGY0VQ77E&J?btnKk^C{^s>2|2(=~4dfAc?>W{;OZl z^MAfGx!>!7SVSqGU2S01^535LU7sMXVF+v=8`G7&JdYJq*TQL|R{CKTs4~h>+lU>x z`|p1?s|j4gG-F$+I8kHg2uYxZ9OfkHK}xi=4*DS74x~n&U&_n1xbZ zr6G-NR^u`8!nj6U4kG1^gCSLOaxJT8^(8bULj;D-$RZ~ePnq=ev$YxW=<95N0)HRN|hqnr^VOFkBIS0IkQBy!_tYDI(TD3|Yg()E5?#S_@f$ zDWww0A!N2GRP8orCqKXTG9=hz9iaS!6tEMy<9mr+-H#O9u^Cg~KY;mqJt^WIi3Ou| ze;QssyqbPC5<86aS?cQ6dDI#hm>+-pcjNR7{r9fRbLwJu{~ti{?AASwGhq>~Ag14< zJuV)uDAzhz)4Hv{N^WN9pdHSpZ@+LKwjy>C5_HeJ4eBbLtsA2rI?rzX3)1}^CYz8FUOo?V>XN}+x417LwtcM6`v zjkQ~~r5ERv&9$}j`zSaRTRDtx-o-*Q4=<*jZPd1gI{#kZNo<{Tvn%FIBTxRE9NxNb znVWYnpX?$qT^NOI%oPgu2R76V-|*dKR69I6{0AUC**yTl?`3ZSWp3d6=)@@k` zdEbfyVdm3lSfJcL!1J@a=B;%`wXM~q&Vc)m{(*I^SGWEr7u`|KyTeDP$V1bczxM%f zzm1DSzm1!-DY00vmjuD+?5~!wDO-*JIBId65#wB*@^~)M5JhcGb$Dew`esUaaSsS$ z=*u$Hq5+Y!gd>yc4PJrgO!D8x$|5M01!4qbJYqRQU*M=FLvUZI(HeWSl!P04 zD1#e%Q2+pu5}Sj}QuCdAx|aGkjd(l0DZ*rIAu{^^H+t3oGnkkCwpmm9w$p3=%S*6c zxO=;>4CN)@?k@4fa%u1nY^pn-sN3u*#lqK5%EI$09lTnu{YO>0Yi`sxT>k(zh-b3w z=#ao(nCaQgQ&B*V1HjUXXj}})NPs2yGZLbRuv~!i6;-xFKTKE7(goU*q2<5C>Zhk= zX2@3u^L)B^I$$yS>Vg}yS-hvFE4Yd>OP#UGn2RlJj8wFv`tSK4On(`wNbm)hqFKZQ zXjlu>qWp-Wie6CVP(*Z>wulf8wRV@bcFRtGRJo=S=khhO(B(BU;iQOXXg(skJ5fsq z(z6{}VXz@gVrz;jpws%~|40%0zukeXe|O)Pu?O7`ocw`-$o?kqYk4@C1s4W4(v>lB zh)t4O03x<}A`0$XE^61e4#S} z!DT0XU3CiSaEaV&$BQS&w4OK_Noa|U%1!Xz;t)?jUsO<)?yN&h*7S5gP@|J+!nyh~ zdG{JaaYf|%SKK__YJ;`Ri@uu1L!N$MzNVOc=?j3Gx;>dTv{r-Ri6&tj z!H0ItlnFPjmxcxp2-x?I73FaGY|@eBJPwq(^#~}#0NK2CDeKesC}$a>ca#pY*i5FI zU19S5%!Pm3dGk2+YD308Q_cQ*f@ig`q-0N~liG`*r7svph-!3E^h?)EkCF$pT=Jnx zqJEB+O!U9j`$O}T64p|%M&m_u-)KeBADEGdnqCz=M8Zw!TNLew8&R==5~zTu2PlKu zvq2Jopj=ZFNJzYo^r@w&y@OpiA3{<0W(Eeme1L`zBLklPVPPCIaEUo=?I0|7qA9fpy7`?WSKAqgh+;D4N^{ z;DNtF@YEc5Vc-?D+R`9C+LwwFkby<6K)pJ@*(u)KYFr*%5!hX$q=p*lCK=|UA*BA$ z6#JbHd$TbOdQ5DT3es&rqu6hGL38Ci%IYynwDh?%Gz|mfd)@D7hNu$Ou8&aB?9duY$X5ptb z0!zMJzz5={eb7yJtRX=@YQp_68&2ysM%eJkxgXaotlk+)e|y9{-IurTnqw=V;bvAH z&hJ%$n$>vD2c>*yHX0GvPr|Rl#C3XduI3IP?;)wl-Om$}9GsPPXXEFcxVR@vMvSJK^_~J?Kn-LonOPXxGp= zjW)v}3;M|PR$`yi0M$GiGC=YY&nNvBJ*W`X9Ytw)X!+7@{2#!6O&Gy&cyf3@)OEca z7gEnZ(^{x*5&EP=QyGs@0u2E3*9C(qA}(g$W7#CF7pGU#Q-4;=-Zi`2P`+RqJhaIc zf%^$4gNciozA2)<@d9KS8n4$M=D|+FjWaPuIB!00^D$cySb1S$c!xwInSX~q3UmuW z?a8iCtbj%SF5QKkKu^1C>XGOQAoF|ilM^BKcbqzUUz{Wqg`VLtq@>WA_k|~)>q?>V zHzzR>M<=#_F#Tv}^_6{%OoFi{KV2Rr{pMYUde{bgxfN4nnDrgpK|%S5$Zw@HSQgWD zJ>8S^Q7CHOK*dw&2xUj>QeljjlgTOs=OBt|sf9xAksi9q8WqwXZa%|m8}`Ix}! z1M#t@dV)4{DNVW1nci9-i`Ird#YLR0JMiVZ=Hp-)hJ^0<^W0_?*5C(%&=M8*`gsb1 zRAwOI=2}=GyK8fIX}nw_!VYeF=bjLxeS(;7kn|kn;W>Lw_mjk#nTiJeaKpKmnM+(A z4!sH-Y7%8xzA^K^pSb-U{jK-`kk~%t*<1x>9OJqCjpIYY4g=kQ1eVJoHoR4A&ZQqH zRTv-@ZtV1a*yi-AA+193N2DX#)HhVz>e&OuBW4c#*g#b_zDJ!i;75gEOfZ`dg^ef| zv!ysE^d1G2(J08xY9pcWb=Lzpa7sO=G#Ls`kiiV1@t~A4Xg^9x-6jh{RO}(j=WNRyg>r^c(Pw18aTmnMbMQJ{|B!#o969pIQR`5pQ#}j9Mc-_|b$?sv_iSna-oH2@cVa3`MO0<>j7~#a38uEZZY3vW76pUSR z<+BFIOx!F`f)b)pPc6dCz2i`S1RZ@=2`bbwdHQHDm}D+BV^tr;)S&KUd4zrK6U`lMdZuWG%Dl>_i?Rugn!@5MPX$3kUjEP^Pw0~+AZ>;0^PGf zGEFp>ycYo~j)((r%*RTBg-wm*4qQLdwv@SfcsWY|7Rq$DHG?ZO%Tb5wG>uand6_uj zW|+>Xq1K~4cB#S}D9NC``$O$B>r6Us4L%vXz!qV5&0K_5Jub+?m0y=%xYuE}RK&6i zt6e}pK*`J%`O1&-0CIIE{=XxHW-vDhX7ped=vzb%%_LAKLs+d6=yd!#yWR(JRN^2$ z*#*E1kPFBvQaKIWs6QqQB)slgKJ>X&$hurj%v@Uv!TE#}$*pCACx#|fVhpKeq4W}6 zT57$KyK{@({~z@8lZuqBjT>Wbhbk(Rnd8_VtPA?0!k(<;rAsp3FXVj(>Rw+6jM6DE z6$_41KH7e){i;U*2m^9ge)-0m~;2c6HQ;EZARBV3B%)4E9 zxg_(94SmBc)lz2Ebm~LQO=fzUpWNM0cO@laY^I+(Vo5*>#*`0TnYE=vk*|`tVv+{0-l4 zd6^tc1xL4Vv!F+ZnC|c4uVDAttgHV3lf>qi%-Al)qTI-)yoTCTG4d6E)9j1XkfSaA z79gvp8-|XMIt|l5E_dHOJH?)S|MOLC z_vAlf!xRM+?A~UWi;c%zppFf2Tr34q-0%tKsf6ojY5r6(%~jTX(=5Hmo&v}beuh@^ zSs4l9nQCI@w++N{1c&?ZhR@xmm{x0;DVFkR8m;4vhMNb#&9l3;54kTdcClcQemS4z z(|hzkzwuleHb90_vYhM+oZ#pandtMMa7ro8Qxt)g<>Ix&|EbhByi@#GPV zp+iVB(`7GU)27dW=Q*Nag4GABTyz(-^&h0{b`^C3(Gt-puJs~`>PGs8cQ3f_xxW=X zeP%^CbNL PGW~Jre{lr0f2;oq`ZQ3w literal 0 HcmV?d00001 diff --git a/docs/static/img/android-screenshot-language-hebrew.jpg b/docs/static/img/android-screenshot-language-hebrew.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c42356663dcf792da8e4597a4f746079bd599994 GIT binary patch literal 114744 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

    AI5xg&i%Xp?6bKh8ixGTfY^ z`fBG`lM`n^3HK^Z!62F&ft0!@|6;5Er-%JD4>Pct#1C?Q#MC8N=Wu<<;lZggJ{n71 zgLiJ@iM}iRSdm;$DN%4UM{pZ2e}Vs5{&I)R>eTb+y~n=*_Unkl*GCLCpkIJ_=Hv6M zS3@@!3KtP2Zg>K8-NP1s{)lb=wpE#|)06k)|L}7Dx4U|4IqjW^P@C>}!1r5EZaw~= z&A`ZsJ{^-fHBsKf19txT3;@1?{;~XD@H+(D|7kjt6&Kc`Ep$Zo)^7DyKI0ae51oMW ztlaZCC|Ainvh#bPm~4@*08gpdR(BLBijYv7IY&p**VYvVy2L})Sc3<<@YNG|1;)%O z&F@kp`+rC#Xc?G5T!IqT(3^;#_~HkGFX!(h#6vOn6o;J$U*Oap0-eGZJJ*a`JP?XN zmR6wlp_3`Y?x_VQfr@g}CwVa1Gz;dDo6HMDNH^f0ai1oX)n1uDYhO z!=>!8_Z4qIL zIGPgCGjC(3p0VXQ-wCz1D=c2_^I~9M?5N1WJ80Tq980&rg>yhL#`cymXKk^rHuVX} zRtv}O!J6Wg0d6_#g@0x`zFzMD7idqQl*Gve1cHQA5^{u#xcDV?lE^r@_|>Aq`o@^P zzh?hn{C<^+{Nm-l>n{KjDc`UWs?D_$K3DB_Wa=i+om(ZUZp*wIoftba{S{SSTIWa~ z?ZIbr#*#P;F+XOT*6;Zgw|EnV?JIf=mgA)HiE7aTef_#rJ7yS#04bh0&QAsQGL;4p z6|ScMZoL2ij^L?qcwS?Xg9^Fgh6qOwC-a$Fe^^T*!26cMhL?u-E$t*T3o*Zn@gG0c zBk|c}&*x_G>TTH$C9DOhjdPMSdZ_7ZNy`H%g87iuaf9*(Fs%nkzoBRy+wM4Ie3imu zs#Y`!4M#l4ozXEK(+qdiax@XB_5=poq!Z>n&mvG&(ojZ_o#!?uZqG$9Kjq4&5zXa5 zHRg>u?t%_np8$S;5BOm|t7rH_KavOM)O_3ObgbFL;%>(mCo4}swpN!9t-k;thGOTG zgPtcY=2y*W2G}sV$S&rv!FH~t&d&BDe$&1>sHCm zJzav_JVtB$R#LjK(c9kpv0M`HZ zY5UJtf4>pRb;UN-*wBo=rg!)IdMO5)EYIA=;M6tXl7#3jb+QU5``iS`&ga}|$-rIu&;baZI2}XZ7Ggi(6HtOjFRSooKVoSY7ozS-Sn<5zTqTcarq_CyqkuDF{cF57`J zBr<-s<2&J#2ue~+#RszF(fs-oB*~|+CeB@+(ZsHUgDDw-l_`eDvpU0Go;P{RzG4lz zXjp5Nr;=E&Qq`>CoBq`5fMmr2BEKDlmet*E=UJ`?>f*LD45hVazT}xePZBsh#~3v- z95>&f{wcEmrL%n4bE>Kt=B*vl+Ebsvg(=1@RAryX31?!x{+E(R)^@2HADCq{u+xPW zlzVr-i*TZhf$LP`(-$LO<348HGhG&}rV}5bhQM zQ4tb4Q-kV4gE{2VbJl<>6r$;t*Qv&JOG#&xejF7P=@mTq$s5T`0Kih`zPn4c&@9pE`UkrdX0`BtNG}GDUa+!rB_zm%|fDUlAL(9)+=F z6}44&zDHCI5o~^t+nGEH*L!GmKsl*T=L4-c|LF@!av}9>L9WvM0?2lhp{ z!u;4;($|C|D0~(ke4|}aTS;%}mB?=2!L?>Gr0kdySR7Om91+_YttC&C&a4Q0SZjJ1=x7w5AkC;cNhrz%Y)cRa1;`4N@PngFX- zdpyj_@k4hvoB6T1X_>NUju6b**wyckf&%umFx72Adp7rPhTECoWoo{Axh_)3=4V(> zS>QvucBq89k4OIE2$E4GwwS^=BiIE_vA@1T?xxW!YSnt zZxL_DkK`ky>sQt*N*7$+Wd#~UY+VW<0~{hG%}>(W zQ?-=JiTDLbJD1S-Ry+WU7_@a|i)3x*@(`7YG_Zv@JkF*OI&&h<1a>0b$@X(abxR#l zIqHQUfRk%%3+>A0$NaRX72M7jr}Ygct->gYg38k4$hSCM3-L9*k#0}LU4?mh%Qo*~ z=|LMZMSCClL&hu*SzT7bD)Be#liDxpD{CUn?bm*U7GIF%itY<~#3+_(o0OtSf6V)Z zPf911^dI0FZLOWWsZWe}RL1l`VApEw7Q0eFJGda5`f7JVic~(P%7Rip$Er~~XtpRA zTx<-oMoDvtMUwALeCiOnupbI zFy(ef$~wKHO^!iEnorj5xpaK~v=_5NN2h0AOMM+N9~@riTOE`9v4CI)a}=i2pO;jg z4V>mPq@7WTA@2`fT}6d0Ciyx>N>`1Dh|py@!p4XOMA$l}Xq2=F0cnjQ0oeS)Sh#_e zAv72Kx@z}>S(0|Lp$X%UcrBY1qE`&bqchKCxv8|RYw!`{=^j)aRLG;Q#GeFwbhOoj zArW6zdNF+8O^R?HX`8#so5XfD;cT9C(OthrAoRiNvOraW+4*kqm8O#)X~I!EdgL~< zcoc#fdx(8mfz+8#Np^-0w$qNG?@|mlyEKIPmZ-Qul}is=cpoOvls_)vJISRZa2^j0 z+)+E`=+muo>udU)a46W2M7o?sS^l;7W%ieF(%#95Eflw6z`8KQFMxn3-&n6G<=&Ui z_51mjOT$%BcV1wz2fJxC&c`A~@T;=?jE(6iw<3(=blD5owaagLRjJ`bKDe|*g^*cx z?lvlMOruDl>k!w9s9mId$^6;dZ%N`OJSOP~#_EvW*1`H2aAkdO5BCnM)h_d>tl}$l zo`iWaH>t{qWqn^P6L0t%R~XF0vpWM?)}UnQoEYH}E_}#a><#79e%YGtKWlc3k>Bzk zS{~QtcHEw)vS@fUSK?pXKw~R)d(p)AzSOm2)v{{B6U>HfQ?xdfS0cLHauu%O}W! zhskAbJ{e?Ar)z6gy)w;gd$cQS+gi5J5TEkNP+@N%3KMaSq{KKu*qsHy4zp z@u|zb5jZHP%!l{T!-%#$!(RYAw)fztlU+x-tH#F0SaAEw{EL=z86gB$E1kIlkxl+U0ys`QkSjKcBx|2A`Ilb`~YOEwG+%F>DpkIC8!xhCoqs zc#VnAyYct`N`wAK$Nc?h@&D14{vYlC`-S%a!n8pEpd0eoV{1UN(_YHa$q{PV-@Yp^ zjTeD;ABx%JRy*v+GcOo)B00-@JofvQVdWW;6}=|j8BlEWod(u&%G`xngzXTt+QVa- zuf3}p^5XU7F95BsF3QD_H)kiyzI&OMXzOGfbb^zziCS72y8k##h??-~y!rc+|D&7t z*Mt9twH2Q=wkjmfVp1C?jg^)~1Z>i~S5@M=pQ?3-TN;f$Ok-AmD~QQa+nqwk%`}k@ zT+--6i(~2Xa$EcNXcHM5TctCaOD};+rmmo@#GzG|W74*Hk)X-(y}9$3o*KqM11+N| z1A3^b&D2?`H6T6mMBM0GDO4d}SmhIDwVRWqh)c&W29}lJnjn&wi_`#tv}1NFF47zX zG{<;Fn_)3;$(tbuhF5-6r`8oJUU%K4N;AK#mqROkAg!)CIeo${b%RF{QL-|nA)`f4 zxgSTe$=W0;hb{Ylh^HlW<-PM4r!i_)cp{`gy_?6`6^+)+XX! zl({p)xb1i>J9lscE#N~KwBMiD&^Dw$A{Ah9$~U()P``3>lQ9zdJZLx2NwpqUpPy*h zn;mnr#|}?7IGd_F^*W(Z&{^}oD(_#U#^Dx9qM~8VSIFzQu7nf_3)O0n9~p9l?)mhL zmGj)P4~xeT8JPqZs}DIpGgBRZb;yw_0a$TK$!7Y6yuGbjf!1(be-Y28Fg#_Xa2_gi ze|#q|Xae+E2c+Dr=?-oOM|L>&$TCVx0Lx#*hm zTf5<~$iDIZ>mZZ+MX~>cJ2bz0Xw@gnJvK8lI(KCDOxJi!Bi_(O+pfOu)IZ4tu1r$o zarV?^mAEHVl)Fe9%pyH&VzF9Q2o8NvTgYwUvwpvt!?4$5-6q1WzB63=Nmcxk4~d4> zi|g5QBIF?rIu9Y*GV{s=Y=mSWCz&4Ph&y_a%PfnY7nwJH@FitU=01zN&&C{y-?!C? zK*L*aHaHePpGffw+KarpCzdJI zv~NYW^eb#zeXI5jiBE|`jkKxr`-v3w^@X~ssODdQH(FI7MOd)bREBVoz5wf_?);pl ziQ9cp)c?cYS3tFyuItiir$P%93KXX}K}&F-q9s8}a0wJI65MrK9D=(S3Be_}7ncNg zZA)-#@uG7x`<{LF%sz6@Irpq}_qyx+YbE)<$e&N%&!6{sQm$VRN^sh%LK0u*tzl`J z6f?PSp3sxrCqhO)MO#0)${<_xDu)d$2Ndz&I3>Jw*NdhdtPt!Q+HsrKx{=$^jVnLi za}n+6KtiO0Bknd$xZ}58`?+ z(DYi1?-`6^7y=d!^s-7y9& zj=trYvK$owymiieeD5_XWc6Ip8nCMZGuk6&-5Tyk4TgQSnHB4-`D==_bq`qCoY+jw zd^3i=o%V9aP;niLP-^GQoqA%*3foJ8g&3g3uSXcw;V?)E5o<`FG*B`S*4li+(4*i< ztIJ)v&{O|)-_syneZ}4JqqL1+ zd;N-1g||&Y+iUeK$yQS0pN=HBm(HnXbto*DQ&`RU_0c@eV4G6?`5$ep0Nw8R_5QX2 z)=xtGvm{M8SkHH)Ely>-yvmQ3KKO!gYns~j?&M+1N+U8Y*YI1y(u~$9B9w8;9IhPX zMwUR1#Vo1nv#7+TM1AL^8iH8Y=CxoXQHGPrEa*6Y3={dQ+!O-v!%;+j3x5(V|7+Z?lc|rg5W4a8i3s&Q6h7RsKd3yDh`7pKF z5%|u$da-gNLLgu81?G`k@=~wBv06B54dt++v!SQ(PBySfZ{gxE*fDg`ku>{d&{g8~ zJ?UTU{(d<8H{$lu&eDs=#;VI! z>`y~xxuqZjfyQ7#RX)z}$rd}YCHf7|sNgdGD1`-4G|0CY(Q@taq9W2kHb6;A5W&5!yb*uR z35tAeFHYFkZomnyUYB?4%amzdLqRkK%KH{!vxfTA)*)H3R~6g@wLR`QVa+&(P15Al z=x%F`m;o;ssv9>jH-=0go*NCcp$UDRAbnl*F1P~*r+q!3U0RhJ>z<8r5ou}Cbicu? zd}>Tp2FrODqgE!34rvzGrKFTZ+WpJYF$P@2#k| zV=!1TOFbY88+di*!1&8BiXv#AntRX(rJsl>2J4;bPa(=KU3cn=rt-J9-PF?}_WS5K zw^d)*{GiHESPkoU+MrVjUM}m8=;s&)-ZkM{8LZ)42X@a26QY9p--f5w)xCs7Ea>lS zJ!IcB{nM;)-IM$UZ@iz#9myaPsx^@1k@#Iaqp8VascnsQzV9ds8oc_fn$uTN zd`Q>-gPr1m5ZS$6aSm=T^uFfM=Z?${z+&UA(<*1jYKH^N)&ijnDs!AS1;gTMOPg~$Q!fSG~Uvr#G zKdyF8G|s{djRz^Q;rtV}0x~f7)eEq+ZEN+|N2U_aIfgFS#2?mQ)78J8<+c@Ukh%Nx zt?)Y>P#!Wwy-J_5hdDJTZ?Htzl~-FxJxf1xFPn6Bv76UzU3&Gin1V{Owy27R38kX$ zBw_h2%IO=)1!c~=to&sz`^pI4_n+r(Qe5es=H3{+vnkWz(9nZ>`?{r3sG6wT#!VQS z#%;V@RtdJZd~?9QZ0Q!Hm|<$6C&=$l8-H~1usKB*$hbR+dC3!tt$&uwEu2+PZx%N< z`1z`Z{j+EU5a7sC15H`4vVE(kgDnm={#l*xEUonIs(fbC@tZm^`w=$|5CpI^#6!&z zl)2~`p)|w+P!Q>gq3q8#=1xzK>+v_#I0p)?u;C_((o7ATjLw9r2L$3F8_MH`qh0mn zBgKO*2h!LyjA)#h{2-Uxy0X4#{MWgvp7{~I@&4@90+Q_h(4rXLwT3(VBO>QqLPTV4 zA(?Anp%w@6fx6_z&g1^wvhOW6BI$%(>;Cc0+; z$*{}ks#%Ybs#%ge*+{iF{yqR4p_Xsflh)uhoX@Y9BrkaCateN8Ick8DV>f9vBx34e zSn~-Z4{{2Zr$T=hY0c5;pKdUodY;e?9h&o~hHMG`Zo}_U%vRL=X+9cZ^)&2?56^`- z2QVEPo!Aj*9j_W84+mEBK)tIIrP8lg$7GITF&BV1c~00{^XcktoAWFT%iBg#VM&Zf z#66I!U&E*Tv-QKVt4WG;E7xa^D3u-7mUQ>OaxZ z2bX}YAw?8zS$ft+S9NRt_{Snbf^f4Z+%=YM?s2hcl}AmXjxGeVKW}XY8m6omx-ZsR zd~KYZ^)gvnP1sQ2_m+5UYiLtLGiaMP-~_O6)*eyPpXY55rpxXWTR3@>KiR%1qVlTI z({oegv`<2*YlQzC%lSEH%l)T2g}0kJQbH>MsAK!5E>^T=4>x!ULRrB=D%B(eme@X< zR7<%<9Q6~TS>D`(ahR?m%yM*ix`1f0CF6;nv+Ig zcD=jvD<1yu_?A08SvNl!x*^OdE}9I5^NAJhyQ#n<0CW3<+Cbim2d%i<61{`p; zw&ssrr;fLb1`S40cQFf+NCyp?3I~CGr+C4UbX*C4?>X!VR%r(7^dPYJTv54lQe zB-YcZJ%TSJM+{ux^n;So`4^ho?E2bAOuW8sm^slz`%PhY1BOMP7b7uI zOBY>Vwbe$}&PEbQM|0_Lqk6*;yBHT@^_yLV2i?Uf)fn zqRT6FXYh2dgx3-1uwT2Q`F38M%B%I0G(q#?aQ!Y1ZaF0|xz~y=Z%6M*EkI3WiE0t3 zYmWtex)_F}^6)3+M8MSreey~Z;$kI`5=ehaNrof=GLtVAl*qtr1A$}0;^X=GrGjKh z2-|Mv29&q`)z1Z+e8TFN-Tlj-%U(>}z6wvuqOBEMyoV}i;gPUGUr(d~Acjh> z258{{uR|84x?*aTU&nU&Bf?22)HHVdl8p?McKmbVeqKMh##TJwtP#82igBZkG}N6Y zYDn72rn7JnI=I2?&?%SsOUbG@fWY(=mQUbDxU&m2V0oij5I6 zJ&jI$ll--sc7dp?Ld9l;Z`#=wZ~4+C&Oc)R0y!vgPzWU=sy`bjuIX{MtBZL$|H}~I zyAK7>49!lGj8-0mJW8;aWvyqf-#O|R-G1w$6GP+vSu8-yehGUYo5Q*|T+_zY$B?r{ zFq^U)CV;9Y2>lI@r~2ytAJ6j zxe<{IO7%yJ)^M z)8prq{!Q&^4w0rpbGIIPvJAp6uP}NiI!;ucA3;3jB1L2!f){}7jivDSy@UW$@eE@p zGl|9-agKAL75XEr29Df7LHNBysuU=EROiyto>dJYWb1}ax=ZrZwvs5f6Y#LX?SKeE-b z+W%}@BDf2$1XQ{4?H<{wnNJ9uGL>a2%|guT*NQ$Y?y$41ur{0Ym3P^N!Osx-ni&Khj60TaQob_cvtxHqat;kmK6WUtCL8T-G3u*V| zqjr2(XN(j^80ws^>uCu^*|_Tq%gwrVqV*@cOWaiw!+=mfKI+&5vb6)i(0<*LNs3-G zALmj=;aJ6(lDdWq^aMU2_efuOKl~Z`tZE!6kgl;eywcpqJ}f-_c|Jo<{XWE&f=Ub z#RaJnY4gz`|G}8ML+!=>`~IU@3jVR*AN%(oD6tXOf1A1Tm5{YCH&NCX2=^@ zM)63cw=kQ^OT|-wu!Z8BB_4-NwknRQ;H4$8t>!RESg9xNTYHTV7d@~cuX7UMu4P>;+S@NlbWPCUFQiISeRB!Lc^3{!Q+_ulX(NvqZYjKRO*d7w|)Q(pbR zdr;k#s$EC#)Q=Tx=RpEIt38Vp7ts%-dnepEx#sO5C3JMATtiBv$(tc447XB<) zE*Xs5u$fsU%B7-FFRiVmBqq*Pum&>nWIe?3ivB0Be>^VT|6~UK)vw=)Jolp9hBYfO zsb`}`UWbKYFr!qcEjjrq%;*H;QR>j$d(P?QI+^BcS{!UcCD6X8h3R*-zvj#BtgCfa zG8VeBu=FLSsS`~2i-pf%vSgGqU)}KC$rEE;lGEL&U$SdikZN2Q$DP)692T^_5|25h zJO3omBHJG|41c+_>@m1>+1~lkTDjVdWBpBz0CTf2TkFz1-`FJ%J5H$wsOZrAtj3N` zjp1ofh-<9u^HPZ@JSNX!dHE6Mt3b`+#ChcEZfA2gU)~}^eJdoVkOwh`)u+(flf}ov ziY!*lHoM*!AMDb%Rp_k5@1}Ity`@nhIOS0X>&2NR$J%dInADhP!3-EkK8}W5P}X02 z#H~S;2Vd)xzIID_gC(k;+;3bEeJl8fg8T1LJ*BT-bi=9xCr7Eg-NTbJT_9!pY9dK?99gw zHH%VdaLA=rk{Frva5I~I$i1SBag2$G6N)+gdH?zCM=G@tRBM{1=V2n$$-}3ie=mAQ zC8Tgct)$;zURT6iGwsQ&_L3!mEzmk?9%S3|d>?3Sa-}t~oLfIDrX*T#?AsQ2MCn~% zvHd$NoQW!OV;>Z$JGS{Wx#--J&|1yRfSXpSFQR8^D4d5aZ|rqdxDG(_Ne4VSfr-VH zuMTdyg;KVn9vEmQmyJkLE>>BQ~?cq9X0Y z|29XR^KVY4E$iO(_7TNK{)TsSw=XHjw=_Vv^WszXE%K&B>ghafv6mU8 z?LO?Hx@og}ud6@wmbjE>Q_)own=sSVL3=?Ql$^1lnxdTSPWaP_j?G((=~Fd+!dZ?! z8-ygwPMRCUyz@j2UI>Hh#_jDo)42C_Q&z(qh3dt=dFaHc^sB-=#G9Hv43(7)?Ym>3 zoHd4lQe3VUb135?Qzjiy#J3wr+kNv3OOay~e+>b*oRwDFm+mbh}uWMvX5xw!G5V&pj*8AL>)I^BYvmV;*n%>;;Pc* z{<971tw8m&W_qj9u}jyNqW7@_UaW;?1744IKn8p(UXOss@rethiD};E`8-sl5k@b+b(emI)(e9HU z(Tec3=?Xi$%{OcF2htI{0MVPM6OQ!tJsXiUd|fn4F3CJ9Qh(}0Rhr~!z{Xnr zM7RZjrF#ZW?ND_J$)>D%N){S>jqDcL)hmSYmRPRsA4F}J)vAC6gf;oHa+~IF=~wq6 zmbFP95lu{a{xmWq!6Z6z-s35pCc{0DZEohRDDvrKhOE;0K*ss5rS{62Zg$R0gGiLb zz?>!UP`-84#dngo_Iozo&e_j&2mpmz2;)HG_M zn4l|aWrOCcNjj9zbTXuS-Ks#B%Ob<(?_4nhhng8|Ww~>tuL#RX)I1Ea_$pW20LKJd zd%rS73=OqEeV)uz5`eq0;6>Py{u=}X3a60Zq{IYzq`0P3HjwBqv*Z7&*ynclKA+wP zZD*2vxcE1QuNdTZM0X+}r{dkNiN>P7e7+Abw20f8{vrqVVG0To8*CDOuhXA;Vy!mK z4FO3Q3G=$mvB)%ZjOfA6(i56xURC>{oT1=$_<3r_n~)q#bDHR_X@d(J541cl~~ z<`n?s8iYUc#(R|^O2`%1FD0F2eM-kff=Qz6g&Bs(>v9X>K40+G-7lP=Zbo2DIdBUq zHrKVgadJ7ZJLPqVu=>pLdFG|Rrg|rBs z-=}}QdHp+G_W%6FAN!#@=YO7T0B|epe=1yxfY^F1^q5c6BOK|^Y|$CVn;^B2x^l&E z17enkvN-+0d>}J|+0xSQA^a1q5xak%*%H%gflB_(D&LCH1D1(;upTffxX(8WRdIO~Mj(CnNa z`;^?AUwsnQ9*uuIdXoL5%QxGtIml~z%nm2iT;v`2bJsib=Srlh_MB=jnklJ_OZ#^} zw*S7qK+Nqb#a+A}7`QGFREI0*@H7cW*J&@-xE4kxPA~W7<|9Ytwl6Nku==55u_|uoCfhjVx}SRVZ{p!=F<=XNF#mgkfKA7X1!9m zoj&)=9~dV7&oj=(3Ys_SD%g;wBw%u(Val^ak3kaKJgMU>c>fC-acl*ut`Zf5c(l)c z#g0>Eco-I(al#9~T+X{uH$HYUYBW9<*mZD8@~s(=&%<*x3O>v+s5LI(ytSwMTTpZi z&rvbbj8#D~dfZi-0VhXHhC^#UZI+}(RKs)FhO66T%bc#!Li#iq)R6Ksiqb*gER|0e zwGr3@zdD1uw}I)$3CECJ63e9U!z)WmU>KeVugs6p@&(@M-)}_phcL|+9-pOEzH4D0 zhYZf2OV>&szCgAiq8IW2ZXW!~Io=x3fZ+5Vw9v6kL=CdK0xgxEslsOV87VkeNqn$ zxXJq8B{cHuU3{x0TWt1hoH75qEm}?#t7bh+OL-BeKjo;OK%Le=e(3W|3|iRWKgY<3X3%Kzehr6s`4P4gREEfUAPyCmg zxsp@bJu?`Pa+l|m1|AxpvkqYeGB$QRmz#kSpJC7drK!t0N`s44!HRpCt6I-=YcmH$ zg6~)W`I6aaGbnav*YEa4tJj!YyD~6O$hu?}z`u>pGIVLzSZ~oLnj8p#SCd zNM@cbxr=}T!!Pi1Pc(n)yAiS0_b>6MT~%;c?gehm67^5^9yj_FxHWzsQA8+c8)HC~ z5{#wFF)(ZNJzWtMC(Y~PYtD2wOB0W$8>C56x?dNuIU6g^qtRgFE*3pL52Q(V0BjQI zy~H!|Vj`=Q-)4Ksywf0XR^g&sm{bN6TlXaADI*iv$`z8^RJlxPpbbxqxomc`WNQ(KcD=2 zjqN{QI(;N-ZSM3jPUryR;UdI)!Wi)0m6uCK5!_w|hpkz{_(Sjov<3wGt&_y!O&)s= zGpn$Gfl70^;ld{r+TLugvwE^oCdMWuLQeo=mZ+uF1e2j&ZrubDO%F(6&hMu*$ayCY z*v#(?mr7|AexYF0hIUj@P++)&UjTE4Lu+qwe<85=F+^h?#B5Mfok*EexT&Tb&LNGE zcJotXYVt17fSA>|wkIU6;$vCcEj5JBPuKGI`=diaPg<~UQjn5jQx^QBxx==eWQ~=i zDnn{5n`_5B<^I$5pTT?uLWPJKQ;KUPZb}x(B!MfpwOx=}LH$h9^8$1J-TO95>F_B( z5-3igcT0ZlFF1<=BU4_6I|)JSJ;=R_S1^bDWP|QQF#Y>`QwupyCg!y49|z8s&>B9| z40mBJCemNgZ%-sem!?_E`jeS(jRoCs5YsVR&Idm#G_6{+i2oIDoH<^}goospzk2QC zwi+(z{>(M>$mh>Xo63*%alyNriyI(%U$Q$3o-{n?=y(d0E5 zzwqeBMM7bqc)2vCTze&OmHY!x9lt*jMte8Pi7LkuCWt-PCs@||l{Oz(gxw%*m^!v` z`aw2Teb{9K)+0^GOg)T9Dx~*ndYUav`Kr@RWnzC_u}pZB_2s@5e9K@(Zu#rASMSlx zseRW1!M44WO)C+Pp_*(;Sl{aS$N-C-*BEnWSbci*p0DK{*@yx~=Z@;<(724%%;KV^ zgJc60`Q8ADcok6=*0uPdu2G@^NtZX{?*KY8c0RH$K8i|Nh|KN2iJdIlbLUMFjS0Jo zUG3`9$6g~N#5I-9?YxdVhN_7ykmME8f{$S{1uv53_R?n(Nv^&U*S{7_S5o*&No}58 z_?43C5fofmPK4Txshz|$?`b7K(yceL+&K#stZg)4&JV z7H6J?-m&2{STxV4*j!g=>nq@oS8hpzW1l)ts6ZuIA@;&TzFb~trQlcZlIktT6_d!g z1k@KS4;FSX;*pYLH?ry<4=+4h38MP4W%M_6u{t=Im+~f4XRhHQ*4-nly{5*0=t#cK z^*PI&*iKFcZv)e|aT4=NO+DCo<(%i7ZsgfPA`5zy67qetvXW}{D}LPqsu7vrCnQ*W zW~!sb=ELG9gS4*Tfw6Z5qfkzb+Oy=p;qBmLn-YsvK&0o$pBw3J`-4cOZu>*1q1UjJuzQ*c`eDqO*;zRXi%PjXuBndO^IJO9+ai-^}+g+PPyu_yRgSz zP%vtU)Sk209bzVz{y4^4+SYNDWIcOok$j=SZ;di4+AHy>-N@jro)B4D=lrwMeA68j z#FVdJi4{Zddr+pToc83(UToo_K!OSpHC-gnT7XCsAk4xyAZExLrWU@72{3+}VlX6? zR@_y+ltCA2ZGz%kAd`#NnH9Cj>>WCJMNA9#Qtg6&^8?3{bgv#Rv$U;d@9+1@TLR&J zDFF7`r4%0U2fGV?v=_rK3-tP_#f%H|HXHtyqp+y<&u8#)t&sP8pTMur9ok%DH^+uYCOsV!!Kz}mG$_R zoWhA$WET8V$R~>+ukyY+LaJr4ia?B%rgaQ+rL|$4c1X@$S`<%IYC-uP!zAcxN`vpCHNZL*w^_ zow?oUPA0l27SfPKcI!7$KaW{HZj-J=an}TYqxRM#5wdUlIOvoKej(pmYiSEWVO_sr zdD12Nwl_szes)|;5_)!Il9uv8WK`~C@K=VZj1ddAtohA~wZsmflC%U(gg#H43^7ts zxf=0MT==0zUH-%FTVrjlGWWihAn9l2qEq!a!bb(?#NvHBB#|Rg_~>SZov@&WqWOW^ z*8HA1%C}r>cn{t2Lnkk^?gI?{$bD)3G#N>z@uei*@+glTGoUOi{Ir(*a*(q|B{*!} zTd1^LOq*X>n0Kh|OouTFtbeU&!2v zE_2Nm(P)mj@zrK>zyiqQiD*&OG&Sm-MfGj(pV15)P-$qN9qZfljIw0BvlnOW-HUU# zNfDeaY_*B4c~ey&CnslQ$`ic*6fZv^MSDeJ#k>CVDJ(0vE-EWNy{4|APK%61&;dl6 zIF0|ZA+I2pqOkZ4LN!VdE|bahaFy>h9$sMLzihyr61nK2mr}yt;QbsZ(+rw^Hz5ZT zH(LNNhs47NN~}!rM#`4iu>}l+Fg6sLr?HMbK4dDNQP?QhioFY%xlSQN?3}qaWk%5} z3;?M;QUC}o;*?v{>HmK6Mt1k7bq8vcp!|EC=9m2Gd9YfAnp*Go{p8k+%ne%~SRkb4 z7VC`06DLybX5Zl;2K*-ph7vD1M#{`wS8@PH6h(KL|n0U}p-0!576l4r7 zXzU~Pwi9RkF{uVd^DAte;1=7|e(J0?Z?{C3Ne>U4l6Ir$FL+O#fB9Q;|JytvXIvIm6;}*u zFb)9%HM_?nSa4pm?(vohTmEToOt_~fr4yQk^{riCS2~yeh^tmfD)HOivuNZy*TJC! z{A8qw&T4uvbNxo1Q7*3Up{$2#HDyNEe#4e>nG0orr=w1>dwB5g68j?9H&$%-eyUxZyx!Wk?QgRpA4Ll zO<3s6%???*`TpQ-A-AKfHUu9cx)Zx%uf5b)Kv0u2L)grc5&)lx=Of{)f51xuXeF#qeX zL>k|!$tBX_vn>Tgltnr026=IUitLz=OYX|^YUPdNjI}|<_Ji@e$^st*MOGmS7PD8` zSt~b>K3XWv_*(eq1niUV=~fQ%>ks0m6$wtsfn;@v_&kWAQAx>NQJ=5xC(yWgwptgd zMLR#gTG%nJH378B3m+KUZm&8D58O*RC2Mr5`%p>Jxx)QEOS78f)nfDi!;#}&jH%O!q?v$4)m&{mr^wztuoi9?u69iGkfmWku#gx3s_QE6bNLdfgjY& z9eGJ)1)LoF(9Nb^x0kCr+bH2Wra{(pFBM$hbg1H-CRk#NN{l0Zo-uKLjpMd!+uWW4 z>#0o?z?)B{ePga#^Fu%Mn@0|AyB_D@9Oi@*MmPCJYqJY+;9h?@Wbp~lL`<%mRDt~Z zF%I43H9BzbL=5Zky2#XQ34vzSVc}aEIOG!NOoVafwZd2tJKMk*WKu&mloLqm@BhQ^ zB&@|;{Qho>)wjBXyFTp0kcuihu?0|~0?06f%f7;FSDljhEO{3VX-+X3&rzR!oU9k{ z{r$S7E?iXJfqazl;|zsQ+_OcM3^$|4aDE=?f?W-J+8h8A+1%4JqG^hqmD0L}ofw^o>t(+j^A5gC z1+S#DN9I?*9cVzu@V?s?UWI$&hGk{Jsc*;bwinUj z$&C?;+oS&MEG4;^3N1OnXKCotK3E*ZLUK4?!q3E?5v?Z-jDJ;$X^?nCLJAFt=H=BZ z4;FqmJx=S}p8GsyTAIxm#}R7OZpZL4KLyD7Or!9YbR~(7%&*9y}~5DHo7AHjd-uwLW(HDe7@rKT3G4<{VQ04 zF*=4&`FvFaBIDjQ1b(y1G6>AH z$Fa|12txOHh%R2*B*%rN5eQ12LX7=UmwEMP)w4{}w6mq$A8Rb`%f@9hw{|uW%Bq$( zK)~k1uU+zDD>7rklE&FqM)L*|1NU+)toeca3{W3VHOTgYQkvV5+wreL2`9QtIGBzQ zeQl%k+NVGL`E=))h=QD5qk^@5SQI`BmBD2liSocO`)_#dc}yVbZ+`o#NTJ?{I{K+b^Vd2z(-dU*n1OHv`zS_9 z>31z29C&~4dE%Ygn7mHfS=J)j+nm6WOGQ=YEo6gBC0%@<ITZ$`5W0-*$l;0Edh@77{WeQ}+$ax3qyC&g@lPXA%#wk^`N{;5HOg7YYBA!WbAk_wtu@W z9f)&|tiI8ohlY(_@Sn$AeWF(Sce1tEV|AT&)qnGKmH$@x27CMce^UN?Cv*S*+cFP~ zYC5P@4%e{YNQkQ8*~iMn-oV5c?c0l6l%j-eEq^MTeUG;P3Blfic80=txtnV}R>2Al z#APvmGOLa$>B@^Oxr9XX#7qMffFgC?LRb7!rgL*yA*xBm(M2fdP)xBnO{&D>XB24H zdmi{=_`OAkEv5gHtqMRIv1LEEa%j3+D5OB-15gSvjV}P;6#IsfUbSAcn9^C@i5A?> z@;(lS=r&?dcB2-f(KbxX&FMvmph*#|)wo<59ZA|fY##_7_#X+A#2!wse&^p= z{ODZIMot3q=Cj89sU(D^sF5X@ow*HS^mug7$ZD7uV4I?mWRL;X zUM~#V>`a|Iubh(+ zqGB7gJ7fWs*nbUHdoeJ9wKle9nbC`8J$ifTI+mdsIoE@vN33m+p>r=J*4KDnxA8F- zW9d1Ivj74MI<6WWQ;E(T?<#iI0P>)PnqV*KzoO&BLrqht(!ZZm*OX`Hh#z?U5)>c z5#7`BN;S(M=9BlxkuR0l)_~wZjth`mj{Ir7Aix1#?JnRx5U-HG-<+_e;BQKmx$)ff z8NJwJKj=puEf)(HDitH88cvlBPMfz9HZf8)1b%&s-XjyPF7BN3&tBWNH*;3df@@gI zNlevKC1f^K`%@Q%E*DK&7M&!Ut{BgIzw1hiiXC65pey~@G822OmYH~hvd1qR3WE=f zr^ZJD;~mgHC=JF9W%qZNpf1m%8du2{WPlPOvW}Ze3OvD|Z8`F0p2Jsos|KT9vdQzIa)) z81x~;bioh9Ib+?v-(@t>CpnzWr`9lF>P*j9p=swr_q|XsQ3*y#yMLMCey^;!aES-0 z9s_J5wn0#mutA#}u-q`MjL4M-slH=hdn&@o+uOO`y?oJUO2hspzm9}PBu*d9m)8@~ zMQtkZZZDVMWjDL5icn(iBj&iLmx@&7I@iFxN8}}-WuVxJJx+Okr?&PgZ zK(8IqU*VYkXJh@aopJP;cWC?{aZYUSr`)zlv^%Kfo}6GDN2gz0o+=_{D`j}46OJ#T zzPIYa8h(DVyjG*PH($3j9!bBRoSisn!{V7W03cSUQsZad*U}l4nKyA;hitepqhtF? zgcHPSZSU6k_A9pY*$=71U->?VOFy{5(a>uhytB=;1+1QZb74kA!L|(aHua(&1c`EY zkek=)CZUtG_dK~$*P31?ABQY3pZ_S`Pm`pa*pm11C6*5SM)4ITp_;(m|INuTqFu5= zIzdC<>^v!6{41ZCn#-fhU3Ok-8`*GsWhKH|gix(W^en87#hMAAMj*|a^W_Ni6}dKE zY;_s}RQF`)_Z(x!QU%(Z;ZB3*GzDg6-Cob7sS1}m*`*V**qRSVz;p~^7j?k{Jl46Y z@a1NXI9jekYBeD}#k-v)uKipnTEX&+6rHjfI#JBR%p3`qT^W0o??etzmGh@vx+n{v zZR3gEN{MpvhPI+t^F@O7h_C0COKwuGB8eNxsRem1O2Nq|%2Wrw=Bd@Y(KBRaH7D4^ znh~YJ`*?Jc51v57A)=DxCg%x^5U^Qqdo*LN=-0!cUfy_9dCK9u_(TsLg5?<2dxvWM zEIL%-lDW&GVkb(x{Y7_e_J}T7OUv4?XGWu2S*+34YgV1QqYa1J4o$J~ZPbRM(K)W2!&AV;vG*G^Mf$E#rXc=yi13>2@fb z`8c`pN+N|9xUfA-aCPZ;>2~eSQ&{KbKzW*#9XQeQ_4;=5!D54!_w2isBaUwNEY_7P zhM@xOxfSdMe#JMenNVuG=Jf>ySfplH@Fyl!Ih#Cyt^$m3r7geY#XhY^wrwPtI6j>d z+{jE35DGPwH;)vFu7R4+$=VAY6HU!~(riFM6RXdO*%p{~80%;_#->u-wBKwW^%wD7 zC)RK|=*-*E)K99Kc|ll3&FIczbXBT6s6!bE5-RkxWDR(yrsmi;baWIwV+-`n!a%!I z1H({d%#}jEpd0@`U%OKfXS9l_ghbg_Gj7KwKfW_BmXy?h%||{RJvQPX^9G5i(I1G2 z74DOT!*lWhA@Yl$l&n{RO?=eF3!G2$>S1-rcJ8Z^vTkvE=6IktzaygfF|2QD1K;!; zuHxl$?Gpu1wv$sh_{WtY0ci=IQQLQQi_D^ zD6+mRzlxTu&f502SxR^Gh~o~kmieLwTLt=f@RsB{NPlIz^UzlZtKd1DKG3(2mB2^} zl(QxW7V3(`Jd9rr(w3$;Srp6u{Ccog^|OHy)LfJ-n0C1?3!Xz~J}*$#*`ct4&VluimlUADvfz4RXX!?Z?45{%O z+(JYOu>{QP#-f+}$0_@8)Y9cyxV864rqA2S#WjaaOFp~8><`VI7gOhPFF^JAEw%7? z(LFHyH2&%e;N<4C3r$GbrFE!0M2pK?U6b^k(P8GDu&=!aQ!W&O=YIax_S$G(VKpai z3w^H)4J74%F}2r2s>hm*4zgZSt;brn&4@mMH`n`jp`H7!$>8#X?*0R4A?`XM;93Yv z$19lwS<$(Z(}}g-u+#Uw5gKJ#;bbm_(kpM|*?Mb}o4SF^$PDbs!|=F+NHH`ll6xD% zQtic4lI7|Yzr=>Y&dNJKuFRZ^od{vY#4*|hmB^O7301ce871yFDh8wg@h`>o|eo>T7|p^@1&?JHz*3ys3+=7TBk83?`G9Fb}I6?tetfE2#?*M6Lo z3_n+&p%)32n)NJ)=0cXf0qbI8JM%UD`3=Lx)TPJ02@x`$+L_bG1>J||+_dGD{;s8> z58YQMpsnZTYRUNtfPPCAkE;LG-gO5>v8;PMih_X15|k`CE=taDBrGgTkR^u&6BTzS9@{XUTW|ri-GMqt3bgy8f3QQu#9r zP2N8Xy0@owLErw6*7v`2qz44k|F}r(&!NhFcM_}er{=lLd78ZUeCaT>J2&)#>}Dh- z^k;eWSB#c_^iup)=>Jsn|5ChMlg7mZQ>p>M^oa@c2e~zCjAT~pKg6hlHBCA?vnQ_} zOkTPH&!|7#BnIR{VL{0U0nf7t2R9JW>jXk6;BTqpb`YBH5qQefi!qRHSV}HhX8hH>70<<)DAi{^^IXQ;m<8S2cPcAy& zgKyOam;9tq`NLx_W{#KZ*T0)P{e6&2=~ju|{xyvwnnj*fihuc}EJ9wW`9G%pzvucN zm-6qW`$zcFM&4P(?rk6S2R}&+yBf{Ai$%{3q&IjrEjWKnj(skAwt9(YGdS-@FDLz^ zx?gwTc%||QK9`{Xy+44tFZf9M43Voyp&P(m61*Rwc|^Y`$#p0e|8vXvkf7$p74Esz zp?{I-Jo+nuD`Jbg{SkRL)k&;Q$92-1fQ+9qW3M9eY<+i4)Qd8{C0>d6>qGvM$X{*b zuU_*a;qQi)D=+>SrT(LiN0pjLkyaz{c6JTSh~`e#Z=~ps1c;M_>1{bN?Jz25F8zL8 zv?=-rV0r`ja=LRKVPqIBEj}n}q!K`OAM4aOG;$lFcsjsII{>%97U@de6~T1k`rC^@ zj&={EDumc%3vWe%QoureblUZMXP-#1(l>|Krq5=4*nT*=k5Go1y3n4@)0P@qkd1cj z$#?o{Nwm4r>mDcRS;Y>*)9%*OF%wP~wDe)EV#5g3zs4bbi2`1XgBjr{dyGj!?uJ6@QrI%Yk zF_Q})e@(3fZ`zSn*9IXn@OY%(pe;8{>m)>|PG8I)Oq_a=wYo z97y~O$DW72B_nHjwq&egL(MChETFyh3bHI&$B8=HkE$mhUOQ`wo2#>VxH={I>6#!T zXPSO^r=k+JP@&|$uVPo0ndusG34h-K5NiYJQb^vcKIVyZ8)R*+EKE*aP4AGfuiIC# zo6e8c#$9h|p&MzSgQdjkKhCl3s;+?mirE;s00NcDF32YHLWYvnq{ge)q(VrdQJE{y znr8vpVYZ?C2}!1^nmVF|Pu!F%58z_1f{QiaYViKe0Py+<`$&A%(|IbA5?whWJaulZh}0I2@U#v_vnYvpe~Gu z6=_)dZgrzuZT%%K2S(ou--yPrWxLw2eE-5eKj?R4e7i7M6Aj3z_M~U>sp0H; zTA(NOWqAxknHO*@q9>V9W3^&{F9zMjn$Cokx_g12i^UkM&j0KrPWjME3Z6JE%haqn zR5P(CV+@ z2uT)xodZ#@@H@hZMdO1$!y=&Fr}XNau1?Q&zrpsh$+xYWRd)y%>h_i8Ez0js+vFs8 zBw|nR>Xr5r>wGF7yh)a|Ji|Xg+8>sqd$^A(&7=-&p}5y0FGnW%36K1=rmm>AjRgFJ zr&CYtom^Gklq_YIYd46ETL=KJ>yxf=>QKEd(s+bPq8Jt7xi=u_hBX14BK_s;QWiF# z_dH(`M@^@R@-kDmrxyv9vjop?c0*%8){z@hY0>+(Cx0yA{6oOrxXrZqzPcv~-YDNnM5aO?x<{ zgK8Q{>t&_w(}@Sg1ry3G+@mJH%!riqds)e)(DT_T14KT6E31Os>UeGH+^C2FQ=Z$! zRNuM1`xUJS%nHH47cL!6F<+kP#V{1Qw?Ma~Q{Luoc5<+yl9wKx3$o5M_KMecJra8a z7U)kFkutbvp3S1a8!i<{^E zl3C?HPB_M-;y2ZD@iXiUN^Ju6?B7_ahEk%<*2B44)xb~7t{)UMqtcB;dm7{tsBOZw z9UI{T=xLB8!J_Wf{gSW0=?#88;7H3-C}Ita19$A`#AlpMMVlmt&VSLf-h4V&QJLE1 zf-Rbb?8+;YBn~DPX2Muoj~?0iqd1sdYP!CD6uR!iZ1|P>dD&u%HwP|rl)aY@Z@PoD z55gL}7d}>__Rg3-`JIBkd+T6M$F-`NfIF@2C|SmRe&`ars6DfvyT@#KnPeCMze*zh@^NQ;R2a5RPZbVO`u)JTgzo{Nic&AXrMFX0%b zCwPmwyZM`{|A?f@9_xi{H=`PxC@s@JdC*iC+O~W;`!2;Uh z$7XR8zrM);bVdg??7*I}V2({2 zjyve&+>c@Jbb%Fh)^E5rA5)Dqg(-L+o|g>?n@Qd*W{J9H%#&7`rzN8hqOaW?Ii&f- zqsVWe87k@S@ySm@C~BX;)>Y~gJ7Db#_-Mw$d=?~+_NQ%<9=JVXz$$6J9`Ive>#Y1{ zT>yYu3zGRXwuku$EFhpK3VdMOB3Epuoca{w36{ydVbJ6GDl4G4rMTZ^`Qx~ax!nY@ zI>W5-kgzgmHWpha1=%ZphdQmbmK6Z+)NV~c^b!jM3c(V(P_7#f>+aFoc$l$x;=Nj} z=Hm|*YeOM(bJm}%=Xz0Yw`y%AsOR|?L+0gffL?X8I6|CK?fLe?6^f}~%Zi8aEoj&p zXC*{JanK0(bSAXdBA$y|k%Hpti~r{om_ra8=Rk~O&bgZkHCs*pIO%b@Ij{BQRJa+= z@N9mOPKj}~2q(Fefml4Ql{x7huTs+Wet1PD6M-XGA`UWxOzcF`ota2EFWyNMtoqp{ zPc=8*XMnFlTJ;6vnag#Is`ft!3G(K~?xWO(wt-C;I6MO2kUH#>nlo!fsb-PAbSORwUH9BB|Vp)ncmO@yDr1+XvqTH%d0_wg{V7njToGi}Tigg2B; zc~dQPVjaZB@NvdPD0V;nk1pBHp)4IWSrUV2#!dGY%kCJ~gX^|Kl%~KGZFiq2?N4H< z@7bQ^$|}uAT4S9zizdkC?DxQ|_vyG(2 zG%0#ih#;vnFd^Hq`4q~nh&2uBk{u&AuY!xkTzNuq-nE8l>#d{P-oBP{z$bD3f*iwC zMT8!|y#dq*l6I&N>(dECLI*%xA&F=z&(2@RqC|@|;KONri4UORAE@}I5~1&^++KSj z)12l{S;S*3H98fwKPtXN=Rdmz85@ozPy4&virPP9THHCy$Z@8)ku5)cGl*L%t|;2(La1krnS3aZ8Waukh8NW>BWMER=upO`6#4szM?O?%B!U`Iz61T zxz;9|&6ctr+vC>8U(nF(&Jf|+9?T{3+sdIOTK1BW1SglG4O?kLgrjg8&q@L-l@PzK zv4GKl-$LSkq3Y2T;3gPL!5q%!v!)0V(ev{1pb--lbMYID=k3UOd2cqr$Y`fnEQfg3 zcWo>eua{&cB+~bK4iYjk?GB9m#_3kbE4m;H^rTzUhE5h{sTP(`osifHEFW3iUA08F zcS$M}O{;4L~aIDJDeGomy{i5+`QY+Pdj}LKB}P=#!ToZ_rGaYJabtrD=HBPT?{>mrN z-n~^0oNf-oM7Kcg9u5YO05|m|0t7_r^Qif@1w_Q(Qs|(J*aMp%b&R;hbG-Q&>!8-5 zdo{2Wl|%T=%A)q3xQSv_VP+OvWQME^M9yWskjmrz9mW!YZn8^c(JM_FO)^NZLHpb| z0ina1yZKYT!EtJs7|=#Jx(&>ku z-2Db4hx>FuURm4L#^Dc_X5*Tc@zR1({0?igY;86>0lf&cJ5BUPgE3Cj)>6?-Tp67= zWCgGg1gy$=@u;mpv8~NjQXT>#kdL(*fzs6!pRQ(l#z4Vfg`!LtqvhIN+Cx6SRQAYa znFlq`xNqlmOSZ09-OumcS}`^2m1mwqJ&B?#$}$-(`<#>bS&?VWM;>OCM16+>K;Fkf z_BI!I5B353k!h=`|DPl2zvp${7WYRsFEwJ+CT{4cW_XPiLe_UCv}0@C(bfX&(Rv`23xY??f0s$0nJ)GV`7xf z(kGQgqF79Bhjkq*%?npnIT302c?RlMd-VN`A2?5aHo^m?LS&mAG19D#kFRI%zF4%j zfWCb$Vfw`?_SeV^OAzdaWIv`BnV%$K=EbM&T<^5RuWQYUP&MD02$5y6|3`U(4qml! zb%`Y*k}Un0d~pp|*?~IAYVQZ%9T?ZyTi8(I8O!0VSRf*SIf5y5yqH674qT24z3Ien z_%%*#C``{?O}#;!$S2XU<;{G$Du*kmvg@ko%@V*h=fvpQSpgzr``?mnTBE~H&e%L* zjrAEQfdZ3tQN73^Je$#5GF?0_h)Q=KnHKFd4G{T~(^%COcY8x3M&NZ*uY?7qgh1hF z+%GHQsNqOLDa50L!2^rJ^M9AqyI*z(O~$~BvY$}jy&d?F%okDPqlNpK*xn{tF0Vg4 zvn;j8?hoYybe3eIPK{rg1O;Ag#1w!_BKv7O|4|4OH+T;&l36Q)Nj!~JxYD?z&&OVw zQLLh$SCL{UEf2Lf(w?)BS`K534Z=l==Thg;fh%nz`pvt?(7u)me0g(gf35HIs%VM) zb;VR zG+*B~MqRhks*cx7`o&7d4T2CefkN$| zd+vS^bwer?vw%SabRpi=SwMG8R-@+6MxI znzE1u+ZZssK|tsiHSp)dlEm@}4f9|}F7^(+&FXE%>wS@ndeBWR^PR}XVIHne`(Ki^Y4D^kcj z8EPXfF^7Q#lq7FRNkmNcRa0>D7QegCKLZTIq!vxUYd2lNhtOx)&+yW`7C2C_=TTBp z8dMDz3?(zk1msQrw0HOx@PcB6yZKR!;6q^y!n2rVJK4K-!Ixovb0|uN6+J_g7&9y0 z8tQjQ3^sl8F*)&v{&9QMZZz*sp!S;d=g+x@;h)pblybB1het{Yocis3KVW$Ub=IY%4p;*pUYz>K4TFixLVoAN^=j>7y3f ziq#_lnUtjR*!DF7LF%wScDO@|`&X2@yuA<^4Y*60v#(}h%y#=$*>g>&!z_<5>Z&M<3QF^P)z3oHicwYH-_tJ_rv<)APf1CS&`Dh8^J`yAMf8 ziI;D1k&U(F*bF(4ohIMwA>IC;ntXldp`qpwo4Vs0jY$!_gA)OXCTlf`lV+19d1q!Q1YYvkUM782LJ_dDXP9#^W?*FYZo@ZjKN}(x)kvZ~ZZin^o z{4M=I>5^3OCLtunD3I4yhn-~A4yVD9TiTYFl2geDsI{!02L4P#_NUV9Zl$I_Eb!bS zayXu6o_KnnOr#n*f1B|9cM1mVyJDj3_N@y!Dv|>+A`t9vI`Cm$l~5Ic-RjJecyXD| zb->-;*+%Sc49xd>^a|Hy3)`a|*>GCCNr`C1Tr-0C*sbPn&86?P(lS=$MqSSvU{uc*Y%98*9 literal 0 HcmV?d00001 diff --git a/docs/static/img/android-screenshot-publish-dialog.jpg b/docs/static/img/android-screenshot-publish-dialog.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6d1e2f6c2abfc7186f33b31310a10ab2f3fc37a0 GIT binary patch literal 86001 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 Trwh1GNCQ9b;=i(T=5*vg0(7vj literal 0 HcmV?d00001 diff --git a/docs/static/img/android-screenshot-publish-message-bar.jpg b/docs/static/img/android-screenshot-publish-message-bar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..12957400a28336b9e386374680169608abc21319 GIT binary patch literal 96889 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ⅆ%ls1U=2rgk8o`#w}v{?WL5VaQJidqZlOty3SP1p;i*tXCF! z6LgkY#J`!D+wwae*TOz@YZ;u9jK*+WOQz zEAKIRBT0?BqxI&*yCtR;nFS!ZC$c7RR>8{&sWn7^2RldlvE;VP`fdA8-)KE=?+_p6 z80)4m^0X$93vBsE4&D$l21_()N`61culu zIH#(Shi|)+9MYt6R@dtt@7X|kbDQxzZiZ}bxv?z*eP58J9pDhMS|`MmR~92`nzDjv za@zMHrEuk3M9?x%8HSCqcXu&}!D)Oa7gNP(R&=u^NNW7m5GrA264=%O8GMaU_pNOi z&G?dpe2xGL4kx9hGBE8XECH!kPE^5kycAWP19^pY?$h0lZL@Iz!^YKmit1+_ox)E; zP0JYv^scmx9Cl(S+P11-K_rF~LCO_%5!xXBe&B|#5?_*RV_CKlc$85tMIV&W0Lof_ zHFE+VUHnbl3cBMGMft}x#Lp1ux>Z>X453=Q)2QSzx9<6kglSW@qR+1a+|w&Wdp*z) zV}zRw1AGJJlHsIiXbV}i!a7rbj~3NdmxL}<$J&BEztOP(ECw6gTZS4A5DX70(uHs( z!%ei!XVcA`Z3L*RnD1=u&LVOYTOF)A$|aoT+TmdwIrpEgj-||2sH!JjTPwjd(CZa9 zuB&&2lv8tD21!>l`IVeNJ=?NGfvP#C)f>L-_!{-b)q{!%yx{j?M6>Mc3?z_2;Ul&!g)4hoq zsxX|Yp&lAbz`co4hV_HjpWL}EI-hleEwUGU$`-HI!_#6|B3JNQK}fL>UqgD`@lS=Z z%PW`>2UXl5&azx54uhQ^4*hOO~Olty%Xmk>Hp5D@e{ZI8_xX*hbYX&gE}swk+HdEDJ#T0 zJ$%BEJ)^KOJSmsITyG8K3!YRR#)O&p(3|M9==IdcE2z>skHX-)O<);>51U@8P{r@6 z^6D@eU!QYleSv)G^k@1iGD+m8iO_|Zw}s^3XM93I@A^YLa9+VTSyBsRg(fs@b*{ic z^7E9AZ*wzWKaE&sDV5P9XMPG}_Fo9P$KCr@_V$bsnB71d32>pU$Z;U~E~kP=VbQ3G zZ+gJ`M>gN4*seqbt6yXy3hw>}CxL4|R}pN>)1EOg4NOBc~Km4GhJq za}ZIe#rhYB3&vo^tE!qPM7#|Ah&k`jdaBy@tFzeM=fG3bQQEiwkLNr`F)j6aHX@W@ z`=&yoJM2oyFb?+uby1yGbEdiVH>{F>psvA7=tAdnoc5Suoc=`$vd63Dg05b0Vq9iHZ52!Gl@=$0}v>Z(k=L6jMb@@10xk3 z??VnR(C+KMtMlQC`4B^Ti~msW$e*@iFM8uY)b|FouPI0f z3#-U&j(p#H=Rbc})bn&BvD`*drH$phF&nA$6fB;_=H)AL(R3kfjOxc}Q?GEqXW5(A zE|8VBk^ico_vu!RAp<%fEw{ld`TT}4{+O^LqOaZP!|7Aww#X)&=Q0PL?$~ac-)v{Y z=Pk`AD}-N?t~UC9;&d64Ci}H~-zIAJCH!Z=KT`VNVrpD)z43lnv8ZT1jALt7I|zll zrA=@oCRWb{8<@+h6Xl5%p>*pVlqNuK@*jDS&^L zuNU9`#X(!PU{=L{TqAC92tM2ur~4m*uo^O=cPT9_8WJ1H?GwE?cHKDS$Mg~sDNPp< z#kW7WgtPl`aH8f5X2{C@yFseCe-0`m*)?76#pqY!d(vK%q~(9ayO(C6p} z*lewLM;I<9ZiCv(3+!UTcGPi6bS9g>6Vg=l7NF3xQVi_0&pIezh4tUYdq_*7M8Hk# zTL&}VJ+EWjOZhXf9j1;H0a<7yms7L}sfYvREZgs5^SXo;Ci?e^Pp087mAIX(^JZ5_ zr{KrutNJh`ER1dxktDW!&||Cg2SSVzb}ZvN;!IHyZ&PWEl<*U?IKhd=_wK)$Rd00N zEm#d9%W=8@#Ky#;M$yXL4Oq8TT}z=!!0A^B)$KKOm*eUZ`sh57jm^vRHC|7Zej~rY z_&yBaOm7ocEm{#v``Q~X+N(O@6<|tWMPMK^bZU}uy}JnMXrS=AuK(1iKW#68g8<}5 zsd^tytSOyhq|?n~^DM8n;$VnCj+`AdVEz@FrrXyG0)=FrB3F>-H`-m(e_nn}aQop9 zQKUa*>2wxa25J*Mm}Q+73f9kD-0f|3%G&nkkX#A9GLcQ5a^5f9aGywV-MaFc*+kpx zhh6j57N-*=LDLz{TC)V?0N@3-0#|~usqgJ+)P`c@_O~E9wX`=blOQEQY2JSM|?u6wEv6gbQjC$wkBlFA`E?ak$eY=`16idU`*UZlP3_jfQH?F@WE)5AZotU&(+3~; z{l!XZV4~);xwA66p`N=nv$~uCXanD==_SxWfg)~G@^?<;Pas?bGYW4A_UjB`>Q(Mwi@%=f4L8&dnrJL=Cx>WVtqLd_umlBk9(CkOLNVP%U40DVSswxs-i7n)` zxW$PP7j_x7d~@+ObpvSNIHw29aFEBO!y#C=I)!j^w2^S4h0A^~DxnO!I&ZM-xR9)3 zb7kGPy;ZHoylG?4Ww^+Y5lnu3D-m<%*=3DbDg!W0SuVp*T$rY6aRLY|PREuhNKIBK z66CQP>9$Or>#Zs&=GvZk$3jX<*MhGmAYvMtvFsexEE5j#IkQUE;0ebv3a{tx+eRjG zKYFh5ckF&OI;2z;)wJ-{aiX({6w6F_1ugwUEgu_o*09@dvad+Os!H1<9u*eS#gFn9 zW#h8fmzJ$`M{n~TiP~&sXE^!@4e=*7uA+odrS{h}aSFYHrfk^u3tHKfH{sGqFFHTHi`_}SboboJMmSfU2t_>XLyEp&$`Qnnf|3j|(1B(wh_eiPp0xfH< zxMOm%l}8N0>l>ECF)H@{$t#@gc}2~_+cmRV0PvE*BbISzOAEQCSq>gE`3PkBi&Z1k ztaaj+=&p1`x&iNZ8yCIVeYNx<3prk02INFj>S^8-r6kI5*0LefUdgohsGCyMu(wj! zY>h2Tb3%XgLyxVGmWC^JpiSwll~Q7DPt+evv4!Y@bN<{QO4+4Ihh|RMrkjmvMtO7nhi%8nd!br(?*r!DAG?Ft(^^j7*Qxl+UtL!;H8aFmQZJ+E3|V>dLJP=_f- z0aUoc+<%wX174+6wDjF& zLi!@I!9Z;(HfKf#NADi*XsIC7wgS0ywg7rYWV;sWZI1W68OwsQSTf zi#>u!V9>#&n`azaBKVI;1#^D&JSC5^DQ@HmTo@c{_Uhd2^$%`MM>jNy<`=APRJumU z({k*HwbqOBzPkRD={PWBy zcgM+^JpkHnGGaNI+GF6^Ax-eOkaCrgB35O^EoD_w;Z3(uWZ`Rk?F)_s2s!@poAN2m zM-lo!r=|4h^BN`M2nGW^TDheRw@Hnriid8yAO@LeRZqrs=1O}EOZOpQilt$Ir$3po z!R8%NZA)7ZSJ`%9-+>lUK^r8^8cuFqyE$O`)H&waETtfVDR4nzb?wx_FL-Jc<)^>D zxp7J%+N*fQy~+tnbR>=@UQw4!Fz6SzaM*%|Vvqf5drc{zHa?tR-z@1K(t|!r(7j@o zcA(c?f)E|qp2nH-Qid_3YKbc|v1)j1|!U$mBUY$TU^owhu4e&Z1d8 zm*!{Lz?M-=yy}I!@o0(kgekmES92bh-&&VoA|-)i_;$n05JL61_b{@QuLT3)Vq+XQ z)GtG^*O}5yYr+;*$)8WL+gC7EX{Sntt_DS-w2nhYsBtSALGK{?pxQM`7>SejyODDg z71!}DuM4y?wOsG(bWp@u^;Eb%x25=k*d%|vn0-rI_$lB@fz;GUbLAz-v|d2bq9B*P zBpR&DjijnfP}wajWZrLN>N-5?v8(EV&XVhU-`M(}&N`5wi?3f*cAxykVQw5ihzvwz z?r^Z=nQtg2>rdMm^p2UDQw$z~z9yfM!!zTuRtSfBxNJ5u3ZOkj*h5Nv$_#s4;4+W7 zP*+MY?-@rUhi|b8>DUY#c?H^_e!e^XvFaBh$dRkgj-gi^&0iewE+yF~gGgH??2q{I zQqMsO_PeroQ%hvI@8)O0VHw9Ew6#-|`)BVt=qs%F*SHuL{62ogO)BQ>ZR+ND6;RpU z4J%vKF-z#5Dn|oKx8_wyY)~93G=N7MP&0k~7 zMJUd(%agvCjI#HuZ(f`<0w`jl9LV@q-I|rT34#p3MP z4|^$DHN1}J4SStRN<7vYj#I8h!Ywku?*VLS73dV>-qV2ih<;z_T*(o+>z1sCoQg_* z%n8}-%_BrBA>y(>I$g$gL+Fvcz9j+XZ3ONj16(O7$%mG)GZB-@F+ba1oMPO~@X{c9 zG~rBHpk932Sno{DHdD=W1UsizPa>9Fd1zRX=_$0D-Jukc;*s590QHXoCj7-Q16@nf zMllLTblrZWdB$rDUWhhEHT&qy=Qode0^21~&9Orgv}yi16}sYv$SfSECiZ?&Vi*dH zcvKf8UO6!XZ-7-lzcSl?4jJ?j^DZl*?@r}ixr5XG$aYhuhN%69Fyt_09{>M9`s(+ zK?WS<=S%6M-MdddyiEo;lEMG%AZjqo)-||l^J?}R}_@NOzsm^q75uSn+UE7qceH;ws)e;Zxi%SssSaX*3Jkyvo zIZ=;K0}Xuvq;Kyo53$>)_6b1nS;d{lb@oX!m)u>r*`cma!uVu@960YA1bB?gUh+Tf zOWPd%Ss8Ca6--8F2fO8QOo%p+i?GnMe57Q!v+cIDXyFb~cUJ6OCgwbMD_vRCEk~J5 zGAv82t7{B^O;!ZvLQI4%Uo3kI`af29#&Z-65c2Q5i~6h!tw&WKv=KtseSTmC=F93^ zic6+jpG~P3_~V5tx1VR~@p00-C?F+wd2GhP_|(_Y7CG8 zq%t94o3wkQWlA?04||ylS-E?K0&A-ss+I;=J#&qhX4{+jZaXHavyboUG}vK%CO2Ud zMr1>Nc?3fT2Js`W%Zyi|fGH=wue&yb9u=C&fi9j|BGUB-7*_M=?2-ye6PxmnqKgH$ zd-PDv3kf$WaxguuI#ZgQ*(XXf3K5KUdJ}!oVTWHk^*I!hXoxadRAeJpQg*t1p^z~= z-$apGmdPH&rI-2+!WaN2+MfOkxp+uZ`>NQ`OV3Afwwxb%({(=P{sxyTEA}gPK2HB3 zDBLvLc=4)!@>;a#y-}RbcygL~hkWF8ET^m;sJNugaMB#=$|=(WID9^1HPMv!dSE}w z+r@AcgrvMPaO^QmESh1(t?wU z>*BR65;oUpt3@F`bt~CsdvEdLbZ}?-irM(ud70D>_bfn>y!_`wjbY&IiEBLsLbuAr zWL&hO)W9n&-FpVfy%~3a(kp^o*`p-6+b!I!;Rl#0Gt1WwC)yV4?;0s{g$_{R=U1hk zBdbj)J&i7xm#HpXVo62&ilYO%gRA+!zd%2lk&AICiF2+d`PNcI=rfksSt6$%%!3j~ zZP_Rb7yJBDhe8xW+5>)LyC1r5-;ND(VhIRsZ@mZLz12!`^&8;1dC|z_hOHg9;L$#K zr(NE?iD|z#YWghA&vp^0lKEmc@6Wch6?Mtth?1H0f+&wE-_mr7WDl!({(-i~_V$Eb ztJIg3Fu-<3yV+KGbHJ)T^#(GJ=MIzmCp%vtGD-$H zklIUQ4Qln$*M$jSw-cTtM#{ncGN=HDv|X{P6vc?ZKROul2Nf8;Lj$G6>-a6=9jz(i zXXa-P&*8Iaz4Oi;o(@6=Q#?e0BC&-XRbTRdC6n0Q9jlm@KUt_VVr+IBez=K73oc5A zj@F(v_o$LHhhBGxHmJ`~8}|AJH8z~o)cSQS7qXvU4)QzHdR*0T9QfqwIrdL=lF(Px z-FgT0r$%f8a5p!0f3fvehi>EX%jb>L>2ReGcKY+io72l)xv`4#-7qB&x z`dhqLleOg)4`a@c=WHbczDrIC2r6K_D11HPyE5ZB$Q?9b4oIfDrZCEY9;zfCT>H~F zm0k?7fp^(Vc_7hZ5C?~QC&&~HIqD{>>NK_3V{Hd>E<~1ZGT!gDc^BU62gOdw8X=#d z2=zqasA#6rRaq!xg%SWma=ss5qY}vf9l)Q;W6vP#+Wsd$$qe{1#gj3a%vsIY8t7D8 z^i$Bm;OJv(LWt@C*DFkat5b~0z)CtU*2T!6%lmyRpdc+^LMP5Ny4f^TAu8fkS*B;k zRs<{JlYtd(9+)+P>Ca!BndH;-9hHQvQ9VzALnrY;>()F-@X3vTU1nLSGKF}6wc%8B zy6H#lzc?jg$``}VfqN`d_2K0pT5jgS<<|Ngab=&(&8CGo@C?WglH>kWes9~&%RUo9 zKBt_Wel#TrE-qBdEHhVC3uIA_0uH`5+xji}vXuA0v)~cu&t3lVIicYEvxV~3@*3YK z{4XiO`BIXAj-#^v&Z+NgdEVtWrOadye_i%wOcHE}D%IkC>UUrNnLK7%OYvR&1ny+( zvSJF+Z~}t|Q!Ef%!<1LP_9{ekT`J*{*TGWw#$qvV%}p0sE>}{SHp}P)5HF=YmjI>w z?#-z0=fQ6`hsZ~KO!z85rmkrE`gk<6^bnAw*dPeG3z_TlcMi&qu3m`K*3^w9B!9E4 z=dPOgzJB zo$`P}FK2nK+hVoXZQ<9S*feNrj*8Uy6iQ-p%~1jVm)Mx|Uvkt<}4i*V=KU ztdPRU&9!(um{oW*qw@Y=oTKBbyE6>o*5(oOzc%Q9qw&8Cp-;>97w4Ywk>(Aq__w}d za@wEQNSwn~6pZ{pJz1>Ix6aPlKZYXNrz!xwkM8FX)qYacuaDG@wSH3EYDi&E-xm6; zjx~IInv?sjv;1?jraUR>1o)MisF;uh!cOAb6=_t9>WGK}AbTdLGegZ!zYL7PW%6eu ztLcFiPLFg~6^HkS*gzGFVM(m?;pzlp&v>En`YfN1+k>3eY}x1r;;SqIm*U;{QWWp10ddzvpsrrf^78^Bg*#wn&23 z^g1$Gp0#}zkn~IR$@ye=M`rvfTdL+;F5cy{TIK%4rcDc5MvbdXePi#RV+p;T9`d4= z4o&fAdpm6YkM@CAj*^pqap>0tp;4SBNhr>J$BpxxPDA;|;?wkFHw`|vCQM7eDv z^&?slZzzvUoXRBG?FRb={Nr?FThOK@=6H_VaZx zp{Y{G@}TZ*?>7iRw>y7Fqt+S%<=j|ko}g*0jfAAdkaV+aG~*>yRQ%XUoT5r%LD4$d zYT5m+rOB9VGOCE4W^6_}`7$VkC{3MsjY6NLAijQua9ea_Msq(>5vrY(k1!QZPF3Gh z8(~@IKQUGri)KSuA;oz?!a4xz&%5tQ@vmomyAN9 z%5dBv^zhmeZ*GkHI_pszKO-(>Y?D(m+q(R7g;p1krVxU{T_P^|Oo zK47${UB<{ekjVfgUfPDP>y!v6AM7mDC%?;b9zz;o{f@KqQU(`-XHGCHR)T~-<+L4= z?cS|c1_5P*{{Uq3iy3VcEh{57IV%%*cPahekJ<8UST2IZoi~TAAp4X0%(va5akUcj zomC!>ZF|J&t$^3wgHDkWDrPUvl4R?g_;lAwORMM3*A(Q4j);0p`+F&KQ0m6MR&y6b z44l%^T~SQqjQl^37pUvuM%PIGNp4`n8MQLCT+Jzm&jU-Qfi3=PrqasZs|(~-xS5?j z@t$nyM?N&l8u4zx*DI-@N15I!J;lS0{XxQ|gn|9Fa&>2K?TPmT$`I!!N-6mlt68EJ zW)p22^>SsF5SUr9Y;W}$~hWYozv1UwTS0>*OO5a6z@h<{$|7_XVG>eA!_%r&~1(vvG3 z?7-q9%06m8MOEXz7Mh%>eafQ#vU_yYEg*7EWO)z2N@gfhECK3OK>3ygjw(Z#jAKzGc|25iK$l}REsCRh+{$UVU#KdthAMr}W44hKSkMPG z0+91wdc$R@*F0staS=}tmp2dgwatA%zIdQ9_Ao)r}|Y58D@C?b@=Oh z8=6NgMtbS{16{&TO#}}MmXv}RI*kvfP74peuDWX-c9y$kSIs9()o}=FZ|{bfo4V(0 zCi3^;%j+pcft^4wojJ+VnJ9VfBn=kzc6gox*g-xdCCV$jkao09;mSI9vo&MPTeN7) zP3u%!B!99_`4Y43Qdj6ytS@9Rt0|u;pSX!GsiP`Zw$Id^ILhGGnDg0M&deaY{WVZz z>Fknl9l6Fs))>W@Y`9aFUwopFkm`b@|I}UCVvdQ4lluWlVmVh4qiH=gEhft84V4dX z^y=E63UMl9(3Pn`ROwz2Py;Z+_okT$;@b{$&D=~sR9HoEaOTo2Fs20eQ?O<;!c6-! z6NNrC@joW?Bn-3jabbZMcPwkEaSm+C-kN9;0#cLI-w1?q=ZrNZq|C<0!w1zPW%=${ zT?dMR+zobj7(}|Be9UKOVp-vj5%MtKX8|^9+uQmuFTU>W>LP0pM*+`r?G|;$VIIo& zDI~m6jUsf#Kl|8$uFd#Lb%wKNYKQi#x2X{{wZeL$wwXgtI>yqQ&D6oR$a*SS(;fsW zw)Rp^cTnSFm0d1Ot7~li@i}*w6&W7V?v@Rw4jyJR&^dK67}@2+X7Yyr#ra&*rhT?s zC+|M)Gk`m9Q&%b_P{-n+-QRPdFd#Fa;2g1baR-_-mbgOE|2X!%kq{gdrkxAwp(Xh@ ztfGGn`S+1<5%&1C0DqyJ`dZ_N$sF|@Af)1)IqP#UoILijeOXQlIb-a2$&kp^QPxw- z-i=>&Pf=z-8U$O?LG0U`o2@;yMI6T ze&(uIyUwrE>9%t~g*KLqW|njjhxHB1f_CXiBq1nbFW~=eolPZ-+J|4ic(uniafy5k zN1Xls*B9JVy{CNGYFzfk+s@dB=KuPFZ~t`u-%J`6!WZ(7C67|iw~e`dk8XAD{c!tF zoC^y5yVz=~gF@DKf+8F;@@)Bv(Y4L5dtgdl>SZWTT<8LGx4M-lay*~7S6wFKu&{pGUv{^B%fe7I*$Ln!Z;@}j^0GBv|O@eZ~o;a-IK z!_Rc|#IJv7g0vEcJ^R~ucK_QeUI`8H2WtNBL$>>GRK0QT_nhMcuRCaKcu%DM>Hd#o z{y#DaMw9>Icu1XEh!?efyZ5Q0Fv2Kafg|TB@FkI2JdjjHPLggz=7==!_@d;`zc#vm z8vjV(9|`;;fqx|Mj|Bdol7O^@SvHPmEX;d9H@al6O#X{do1U6LUx9g?T;ebDgU-;am0wlJXasg@kzu@2y-~+jUaegj$ zwZ|oRJ@fB!A+XlhSfD>Jx-3GZJF=P4Fv;grKdJUgUnU8=#gkUX>o7ur&Uc1BYuL~? z^foMweDU+}Q6Lz!9liftfz5BjgA2yKvj|-RT3x~9l4|#v_u%H647IzGN%?)lX8Ca^ z7mtmvay{-G5HXodS9(*6*V6pIB`(Y_jJx-ZN(-Q4;9UT&Pu z_IYB|(6!H|#LQ&dU!3K)r;Bg-@CF&6?l6ewL{WV3%*^GDJ%_CCQx-{QdVccjK$&qN zroTAs?Q9n>ZD!Ov^s=&MSCV#W9E4Vq49+U7lYgm|?iH0U=Yo}1Im$?%L^V#hO`LvC zUf7)9RSaM_@8#Vxe3Hp4Ua)pY1{((G`HS=8Nd9{8&0n0K{g#lSY^lM>JfUJq)kLE$ zRU1{?66@Ljqp!2p&}nP9uB5(oJ_CFf=qmprej_o?cbV>b zeN#f{4qyGh;!?9?VY3@GXXF9AjXRD-6Fa}~_vOqF9V+}&(H3w_p=i?A`;>2xD01N3 zzO<=7o-_0H@X&8S{L$ONP4c+pIU#9_tYCRbmrq**p`bDsM(B+7gZntR><= zMPXJv$IPL>D6Z?=71nPh3Wp`bcytAlXpI7;Ei8PWG?A)bw9@9S^IWh_?g-ykOo?&b zkDJ$*=Q$#3ZvVsHl$-(yknbBcd_8Ra7l%Wzg+_c6fVJq6;(?A#zVh*_TXnUcWv%z* zpI)^Mnkt$0>N(9*?g3o}H=5JPILw6! zO+gf|R@L&W7RKrJAv;SP=u!jB&P$}nc+#H~*H6MwOy*M}eW>qA5& zFt#`jTs79J2Ky=z7cq1~{**RHGJ|LH>|F5N)xyM;QbDEj^Hk1;x+?kNXzk^?Zr#*G zuNJ*l)lHN4Kg5?6ms=TtT&pWBHEI)4&OVU8IK15zJaZ+Jnrpy#J;kQhhP36(1XF9V z2{Bgn64h);wf@*X;KrQCh6OK+0y{i$IK*Fa<`o@W*~38nV1I~VY{7?mHk*ZF&u*{u zz+kF+#8geSX4GmTNrW}rBXD<|LR*^{DDPaf`UKc85=mEo-SW^s9^ic4&tp9NgAfO` zO!gPYWNMtYi15wz#&ydB+ECM*2;8F1s_J#4v$C_D)J=nLc-4#H?i^lKp4K41b zI^_*QgNFB>zz|1aP@t5i%Uh04TdLkwa&qafg+5^(XP@?cvc>Fv#jVZx)?y+XX8Rkk zTs#;Do+D0<NTAS6d~JcuZ@q;~v+>--W@!Af%Gg3v zAtq96$Ya~x;p*db-wc15{npgjIp(yLu~oOXcMYOV5%_MU9J~J)XJeNc$r~BJtjTg0 zoNW*Hw2qgX*H+v2-4o2arlZFmB^wKSDGd8L8NO^rXZEnnMT{IUn$-YYBz{fo3kv$$ za1mPa5IbcO&Z?$V+yP#j8;K=BgXixafClj07=9V)n% z;7;-2?$SabK!Qtf3c;<#oqpNf?|$z&-*cYt+ z{UqTCSD2!|)(-rpfeyF;09fpO>3S)r0drdx4=u{C9rwLb$ab+)Pg&ait`MzfPWJ|~ zG8Z%yb_;q(L5?y1dkz~y$sAQR_IvYu9Tj~w%2u^1z5Dz1i856cmEp4$vqaDIPdK|= zCpexbpQI7+QHnVA`xH%6x1_wpbA<69v@dy+kRqmL+1bk;_v>j^JLD0Uw<7k+z9K8@ z-m~Bh!glADjDx67Y8$o6D|Tv zJ<;KF#rg-vt(lRrY=Ty^vpSUdL?(Q61Hd4g_S(8<1MBpCE%m)xl&fV1%v6 z@odf}$yji{;r9%kbE6D#4xqg2E`}%;4FiYmmY&MBE4mwmvaJkjSe1TtL65L$G6-Lf zY0`xZn&)N5h8b$^eYR5vHR&tHOn+KoJ|JBsN;=s|cnR;VvJyAn-JSD%XRkCH_JSB8 z=q*C&ch4zjMR;YzEMXBZQPx1_h#*L-^9Je zXkP~fglP+rp6P(9H~D`1u?na3;-ed4Y!_S210TczC9vvF;oUxf@RYOg=YqP?n5%8_p!)PRi;b@lPyfcFsewS$l04<};>>4IU8K1ppoMC)`N(H9)zQbgX` zSvD!Ky`a;!(o``3#n!G~S9jm?orlFx1pW)77VgzT_>(~ry0cWNrMJjW$7rv&weYZ1UE2CiOnG;n6r*r8T9tdOm+Dy7I(GuX>TM>`?sI1q0^+v+$uw1~9G zgvrEl`2pRZa*Z@U?I0gK#FW}6vZe~+U{YFBNxkQVBzt(R9~5lMK>=;zjnBpAb!6#XkG4@6}$|!$@O-x z>;|{7^>ivq3aikg9j?SKgNknN0doP*F_Xi8`+1xc^dSgdj1&(W`~~&ws3)zBMlI})d~?DNV^LiH})md;F8Gn`MIAzSR{w?4I{Lj%6lf6vt*hiBZ)Rb3nBc1?@L-GU?H zn1-0$z~Z%m4cL4?)`Q_E+A{$PBMA|b6$FDTDFkmXtYby%64QH(;^w&8<8>;e`g{pK z8&oU~rxh&?B6Mq7YCfA3p2pz9G$%Z|cD$Vv$bxL) zMgYLudiRS3JMN8$+GX`GK+$V>{{QwwrSWl14oa+90DyQWzCq@@m>MB;4pwZ9U zLef7@IC{hh)4=&NSOQYMyDUV?p)_R0O-h@~J@FCpM?(@fgn*rtI-b>RHjE~un{8icNWRl>}bXZ z4gvP{*UbFE``h_+i`M?ULk=s+sHqXPcwwt{pQ$`He)i!^G{Um^Th+s8D@`L^laij} z2m-ig_RnGn7LwJIlq$+vi(|F{ocda8Es0}MTQ$Ey)&t38{Y@oAHiKQYDp^_lgD-n1 zv=N2+T!9rv2FdvYzd(p@`t|C}t8_$skyHjvnhQ|gJ%+P}i8olVXRkGZw;3^1(NHkN z3jVZDi1}bZcIoN4Bnf@OyI$eACouylB>2x`1P1YsSNty^H&qtL(aaw|0q!S0mPlL1 zfb_KF{{mb==LIkox`bu?(`z-1*ksqLSzuYQ~mLmf4!n`x{+*6nw1(&s@% zktSu@RViM`Y#Q5G5lCwaYX0>j_qYM$yrd%7?qXkOWqNiav;dv`xWsDZwUdXMpdhM0 zGYz?VFdlnhaX51t(&_B#IAOTpmw}M-fLHk>L1%1y~B>g$@t3SHcRK zjBhVSA~e^xm&ck^?i%^{`z-2jp;P!?u_{MibHPnek9Cl4c;<&Sw2Jpggtu9^Yed!B zpn3!P&*cSg4f#}A&XFlm^PU!-p{VS|B~jNduI3q$A3W7)XP%Pnrp9hGe*^ky_}JW3 zy)f$eSYzGX=;bqlubJ~lUEgN1Gc5S{$1U=lAThr8Q+iYyF&PIj5HWRuM`8@P?-DMM z`>vJyxNow*CA1P&Qzd44*Q{%1v#s%FC^d+t78k*yIIT$ zhbft@B5(GL5Q0+_D9}0oYOer)TEWm-W|BSL;#2QrNB#Pr)Ep=hDpD2gPzkX)i#C(v zw^W}dZM4PXZff*LaP@3gL?s~krJP=~=fDvfp0LwKm4m&(-u#sXmhnVB>Mpk|J$HjB z?cGjAMP+%1Iu-BJ{g0_3+8y;uH!-?r%_{m$p~$wJHyLSmg-W__SLWmryUl+AQhBWT z=6}qbK8UEJ3}ch@e8;KGv;lT{TC9hf!ijBpJ-TBKHpCG>W(z2OI@k)!mrP5;fXP|L zVU=HtHGho6M56$Q?CSOwZxH7DG#~w>PZPGoO_R|B17JofY0Bmy3@SW57Io}b-rQTyU-Z%G*! zuDG)IfXCS`b@f6(+06<1Y-IIW&0h1sMPtWpw7xwaBIrmh-j{y)(R3=Q0T_m;dTu(` zy~&Pn-HYmnGRea#LkJsZ0!FCy`!!W+{or#f-HR=QSu3-jYxv6do&g^c`jo{U;o3GN zFV1Prs(!UQOq)D1TNx`mye{GdF(d(TtXc=Pse!eM8s(#v;Y1~gh2p`OW$FZfla>#jKb`itcb5Usmg8tHD4a*`A8J=4)9$ej&|>q3+aae`WiG zk~wb9%6&S^y2o$rOBZyXUeKzOeh3Ko%569%9slU#M0ibA3VWiE?mq10tB`RT6DgA>*czApzYc26kXZ#N6?c^P6*7| zy$6#bHQ~&*VoIrVjKLKV|Hw@Bp7eusN+Y|-z19r8_{Y-;wKSeQTguw&1pSpv`ve4@ zUWaW_BI#DN5|%r@M$=s{Qr;WsLsr!!J~<#qIN!OSO;36+2KP;92R$ItW=h6MPfx?6 zBdDLF^1!{FN7%vvmihmHyWW;7tYQUA_Yb z3ojSr`E|ND4w~1-AU4;NUBPR90btzR+xIPh0p`iOwy-f@oubQUQ$ch45AJIAf_9XZ zeRLpKLtJ-3cMBuP_L`tQbbZdFjFnv$PnF1)!_6CK+p?%CZxEOy42iU5hp@a6Vps*My{2vn^+gcRGEFuDFXot zRy}H@jFWLV;p5;QVEaK`W0xf{A0{_xxh|SKp||gwWt&u2zX0diR&wcu8d@yDPtj`P ztH|pJ$IMuf*cbiK!jkJ=^`wtyHzMB+2|$*+7`Z*r8B(SUp5o!_F;MbcB>B0X*mi zjU3d!ZdVhWx$;w;sORjT+eY~|*@`F?&!HxG+txLVYm%zwfF9fw&%ZgU&2c@MxGdsh!Q=hta;|vRW1D2b z|7lLAu=S3&;Cc9TA&^BwmFZgk9By%-sQcOt&x~nvQMpLQ!sKv!ORGiJBT~7QRJM`M zTU1WNP}he*x{-bMVn;DTE41q6M*Ry=6saPd>?XqbW6sxRUX(H2K}{`mx!@RwIfok||u?Sz86t(t~Fw*0&%`LmPLvpg);t|I{;1OeZrzGqqX`+6F@+h?})7$Qv;z1@#SJAS(`J17|@1!xNbiF-Zs(3T00C%&(Y_u)6_8S93Kx@ zHPEz*GgskFw=GIgOSe79iL^eh?ZBCyDxEjd$ij^zVU}y4ta%GltPm2^Di-WO3xWj) z#8r58om`_+7@{N}KJRztdb66OUQ%PDOFFUl*<7neo#t|#?+%(iS+z*xg zFbY$zy+Tp=3BcQN4`RGwt>FRe)o_Ke%ZX|x7P8B}=(WOBnP-vZ1T!WNVCHxLz^jan z-N~VBd*}IJeM3VAX=808E?Yc*U!Q${908u(6S<~>!2lb}qKQpDHdq>CVjg9*rI6f{ z=whJ(xhIjXiG@sw=H%r~Ec{R~##mH-0Zbc#9JjFc;|AXPFQq7VK|ii^wdVDZGd0S? zGopOX(8k=+mq-1rX~g?p;ceSIC&>)+bzQNWa2hegNWIS?R%GlV3D!__x{)n1d+hbV z;06Tj9>^r|2d}U~WjyRLD`T2Uj;n6Alzi2qmbU${`W;PPj`q{!0A~(w+EEFnj_j)0 z>^_@0%LQ*PW7d?E#ARV!vIa{RRuCqc4ZAz~+&(t5dJX^(bz2&)3gha=U}Zg{?!vtb zuaDi3KqqWmiu6vLoQQY**uA~~p*1!zMe~Ye;`CHv>-ITbh7uG}q+j@8WmT~9xJD-c zw`1aS^We=Lb`SmW?ZD?iGXwu`1#4SEDzYCmzi$A;)Qa_v+M%Uq_ zD0Ei;a~Xhtq)+q0@@nGtGn(ZFbZdJz2Fo^xxtP2KU430IS&mVg;3@~qOHin-r0lAl zg+&+tM4T31%N6}Y;P-tclsl{MJKR`GghDDL=AMCadbp-@_9@f;A@J;96u;ot9VXsU z1lD5~Xx{(1PZ$dSko=!M{STl1|DjKpT8*FN3ft$1OfZ-bxm^Z{GJGNk^)AYsB8Z-7 z1Jz-P#BiR(_Die*JumOX2G zNkmkVY1|aGci_aF0tD=KK9V!IN&8%JO9m?S2&#AdoA_uR*VfCUN$0-e?UZSN5r||- zO-&+aY=dhIHFD%D0mhXYmI#p=wcSO*g5GaPsAZh`23?E4U;6tJvgyYhawivBxAb?h z9RR`x#2xXQ%%n1zfq)zHH=2E};v!kPQpyqRaJ~#!aaXV%rf@c^kD^QSZa`YYa5MVU zHC1EDOg?+Rr8`i;b{0B_HZin(%FYq^kp0Wb&>%~WMcjdL{4+ccW33ACR$+~;-q*(b z1K?&<-qhYQ!ljEc4l{F5C!w)H8YYTbCLutJB(e<+Ek&+T3(;1xBpc4B`GVFLRxPe; zmsKCOQO5+cm&|)34)%k0<#yY?_2b%nxFl+K)#HksM|1W=3yLhjP2xiR-WP*qk&!0Y zIps}}JE=*U_9SZ*(AflSOPVzbT4`YYiG^SS!wD2B2Vr?~Q3>_#GI zrx~uEQ!r_(o_4cgWqo%DPo@tfhTJ{Jl<)1O{VqL!mz`7Gw83@e8fqea3i zxqG`AheCai>&0wtxHI;6#QLvfsF+EU=PGJEdUIqxw{9PB$e%-l~(&RIUJcoAiGoB28GS_9)Y>Lv@j z%{~7GFloE^JT0oZ#<`eA6D^^$w*KIpC=5g$+i1cTBP$ghz7#ljm>S`M&A{g^h&K$a zrDvMahcUX1^{9?SLnOgbnU>?`8`oO18ssf6oEjv<^OTGSxDORH641DryY@ytLewWs zV$-CJ&-==w(F1`d+P;Ix7-uLl>N6#38N`J_nxVi1uyAqv2MZo81oh=(o)Gd;CTA7y z=XT`uM4fCeVw}v6;!;~Zu9s)HBnTiO7+11^ya(-?hlF3T z-|;Pa7j^qZSN)dhD2V$-Yf$^uFM#~eIXpD5-njGkBN4MTnC&VGaLH}&i| z=-D3$Yd8{`(-`U*H_J%LzkfV+_BtlpA$dcLn|DUPei}Hf;s0KK2Q#3UgFCa^|E&=o zXVgq6M0ekkx&LWkarxPcr5_tylh0n|pNd)6$ejmq2Y&v2w{Xk93CTe&p4H8m`!8?& zrbQ|B3-IMMyN3#s*3EbPqzqu{ZZcB&cjNZI2NZMg@!wOM@I*^vJR>lbokGSoHK8-!6d zj@qXew|~i|Gw3)-=&t(c!CwN1#0bE7Q)1ezVsvEGBu?n2_xy`8W{^=SQ`h?7|J=s( zABN@JEDr~j_v+opu#E2iVZ(om-FcIHE>VNUoQBk|Nlo(NBt)? z;orhoz}kE85k98w@ps=5%)NgB(l3#s@8yF2R={6?jXSqnv+_TFYh2Wf(SPjq-x^W( z___L3cHKKwlV~l+TV-xZNevkgq z>i;xUc`HUCViP9w-0A#dnEZyc?TrA*mSUjPNqQU-%5#i`$UEhF{P<6t$N#pH?^|1U z29dRx$%?%27vLgPGc2aZk`ZQXDY6aX;RVTW9247$mu{a=7kZqWD~+PYA21ZT^H`5AyFB)#vvN+C#M%*^G~o;x}uIN+nj&{gzqk)jQT) zPBPikJH!m=s>>b#_>)6+$y% zrc3RQPX9DUIaI}6V9`0bQ)ZYAI8vnHW$`4Y>V3g(6!)N*_LKG$#S)9PBjcP+OfNT1 zEh7wy3?CrW^XW}-!2$i8h7sQFn}EKi$Wl;3Y_2Y&`}+s1mv)c5eA8RwnPF*!?{EJU z;9_;>44#^7OR(I-x#|c=$kK38@-3-zWudS;*uAOHi#DtMoJQkkW6)&ykv;Q|p3`I_e22);Az$jOoB%oUzLst`Krs-5if*f=sa z3dv}E36{Q8W+*xwi18!Y@^d9>d)lA5vd(3X8xhwu`v8~I+k2#~6ePh?&fB@(7Z`)kVpn?FfX|k46 z$(&^|#0AV5!#fdMJ|4^>^YBCp1meNuA~JwL65R3eCm_%zUL4Z$i2QrtxC(0m3mC*( zs)b4O9kmr$;7#-IlarG_4_p1mMYPrdyhL2RqNQ0&DFokI-sakY8K>y_yRlcJ;Z2F|$Md@-?r18&3c z(I!pj*9eYv@}a~H0!qilD$CGoKz>}ymapWLEgB8z|1Mwe-uI@v_bRzZ|jua8SO1HI8nPEk?#l>8id!P~kY z=f)|j%Z~P6LHE$yg5w>hvmFU)chz`TyfQpesC-kUb8~WRq!sx`9gP`Iz$55SK_@BgHKFgjxNf`vaah29f9? zq*1Fq>ju*|ru1PQmrU!i<=yQga-SZxZ1L8NJ*RrriLr^3DvN-Lx2b#= z<~B*^Oe+jup`B}!ipP&fWjkh@QH})n$R}4{#|}IEOdM!m9aU}U_{?jk&SJ+*4Q3p5 z=IY#ug28$z(qlCq?5-TCo#LB6W09}{{1w&oATEU>X{m``J)VtM|3x@50(>Fh88Agi zyT}Y9JXeEx(2Hj&M4PN*AikvFh_3ymhF<_B@f5K?jgU8GP7tMWY ztUP6A%F5=z6q2*J);%Bf7Y-JE0fp}3yX6YDO&Pk<2Nl@jVH3H3G%U-nFmIEc#if{7 zhE5)jfO^36e8Sfiwc`rN@EFzB0)8kDh@S67Yg#;+CWzr(Hja&|rfXRQrEa{wfZanr z7xpqs^JJ9fR!M=~wUJ$^v5EF4G?JdpE43_H)0`tx2iR}rP^Veher&PpkS@|ZAD_|X zxzUjsP;5XPWYM61HowX_hMI#~&BN^|`UvX}Go!drX(kj22CsD~pHEoA%0I0K%0FxR z+Uuzf`tea|lWvw`bzacFZZWP(-P@~AbNO+PzgJ}k$XEIVV}l^jU+KYee}z18amQnF z>)ZRJKY|#*6!DZpV9CFZF9e<{Fo5{dsT4EypGS|+so2p=Qbr0I?yG(86Gs^1;4dnsT3zK$*t); z?n5R({h9k4H>}fMI~h3(&v3bvQNdkBBkQP#T|;Z+IBH8Mv^~W92cv8}5?#pa-|QDp z@X_UYc4)G{Q3`CB&rbC#L}q7o%Iy&EQD%cBsHdpI*BEmwrpRNMYK2&sMaKF^ zxhMvy)Z!xD)R9>i3*`C4b?F z396&Q1V>4Aqi{58Ql1vF$p1{?<}6h&xgu4wkulc7#O+6YCNl{(F{9^t*`@vq@M!tY zrR&O;lmHXFL4vxgvx1!Fjou5y5X7=DaT5^=#TpmV z0Y|Jqg%Xo4MILm_p`*-=fhw)aV4F-A?6>m}ndaJqoY#JS9Y5|ljqf-?0%(JC3Ltah zMn1`Y*%TZ4KP!no188|Fr5`t!87jCeE!c{QA_wFk)OOTsmNc=@JyPb@Q@a-O?& zeZRh4>Nwa8203?4w5&XCS^9C?KN@2{=h5BKRDZZH8RqE6yWWC?5qcnzWkwF7RQR(; z27EAfdxvFCI4k$U*MV?wW!b2Pl6s6`7O#HH6nYpqSEKs2Qb!kdbRu|J1YjM9Dcyx zWEfA=m!UC^uIM-I%h0rAY)+w(j&y9`&&QlSO`?+n>jULvf?DF^(*=YUmmbv?k}4{}0_c7X3t1({ggr!M_u3E2=HX%7kbSW-^(xA&vP#g6%HG%OmPc7t z4I!2!0ln?WkQp~(ra?ARUY7(S|0IhvU_}(Wj%01^r=sZWS#Lw;k&+YZ9JPuY{ET}A zS6uZX5$zE(v?RhmOpT>xLG~7l8m1y_#@4MF^avQ;XEnvCdGBxBUjByxDDZ-%+tpFTV~zqUyU=5gIX!T<4QO;JB( z}^{(lRuFkTFdHQ&E`p|*^O@qrM zYQ`dgtE%LA9Y5yPW{SQn_N8a7DSujQ9Vu_B(?Ajrhzosj?0@(E=Hl8bdZgj;Crsvz ztYkrW_?^^6MvvO(fYqIR@sOjmoPs_A=_RI^#n$Ec^nC0^gM{bKOsLCSVNM$k?68&o zpBBesOsJVJ&_r-$6RakNvvRIgk?4f#5tN~-V*yyk{>X9ugOe#a_+ z$k2;@3xj?x17XgM`tc+!_d#5|4ltz14V$t4sI4kDD)067<@gF&e~K{0i;ibc!f)BQ z?W19DzJf!537j1pI=0tq_3&q}{TCp=nxi8$y0vRO8K zHgS1#*5h&job&^Fy6@OTn0+N0_VCXnL#Pit_SQ_mSG5l{%ZjovAxzMWu64y0qqb#I zQr5h$G93Bur1a{z~(8YL(M#y1oaR%3WOv{S3+3OEAs9~rffD1WtNS~=Ic9?yuL z^>A@cuv`ZCj;;TYaPQ1rU8-D|o|{_ijXlEQ5Sd}>Y>(0;?d)c3RTnGIp~>&vjo36fDY@G+7T!%WEw(QE7%J&pXKin^S$z%a1P2lMWVj^MW;KU3p~@B}iM#*fy)USHwSJypWNz^j6*u zgzhp71MK?yvn~$3S-)j?hgss>et(#)%4j=m=wMtm$KjkS6!$nbwLpB-anaBvyeqeQ zsIo0HgxjW2D8A^Np{Xj(1cP@iS$-sq&j}*9H^eN;o=g5CqDy7PV+-^`MCD%XV@Hva2ZHX8CeBFg=xt-E(&rZykkOn|v~lOpSXy z5$`ZDEod@cJfEbq(*Ymh_X8Hp5j7$e5-M;>SyOCvp-P^N;pJo89PZ!3OEMQ~g$4!J zG23DZOg`#B^pBKIV2ej>Uu3`yy<^O{1 zB0YoLy|BC$h5wZ1*b2Acx?#%pCP=3 zKwBl4m2{-7qSSZ_Hme3HlsEO6XB+6^de{i{{>H6qDj-+IHpay*!la4{73~sbyh%## z&uA@V1XiCxR#ocdI0LUqnGI?<{bdp@hm;{$jb^!1Nj~szHzJW)38I6&FxPUpxs@L$ zf_9cF%-I1ue%(4l+SEcs;Liwi=%Ubr#_y;mN-CR=V0Dv^QJN$V7d@53))ZU~ny27< zY>`(2CN7vw*NjDSs!3gGmb#>A%J6$jC^$y^2=&~@v#xY!J69oq#|g+km#-ruWlk45 zIkzCS=rSNKvEpAiuD=vA<4FNDp{dT(>Tb&kAAWBbFK?IeytIF{wAL}p7)eUnGDT?U zzow%I>^0o~%+XvgyySB3cia3TzT;coyeVe!tIX!vN zz)nqux5_c-;%^#GIHs;}z!oN1{W%SS|ANU}4>c=mH&jOYDWcfS32C?!aGtzTzv%|l za~%G%eQY7ZYBA#@3Uh@RWctd4HAlT_GV!qE(O7V?veQVkvdZV-*)h}e!piOwuu7T{ zW?)bA!hJkzaD*~|sO2URG09Sft`_o+7fh*S{LzQ!rzH#Kh#<1J?87)?Je#BBkIczu z4ygvr~Bi(1Fhq9-}jmnCZ?2(|J*F-7x5wc z)~3}&hwTK?#`iSze>#c3dW-QNzpWd6yiWBz^OTvoygUj>!AanF9?R%p@?DT#;m8KvY(&+ss7?3wr~=sSb}zBVOfePSsN=*yS#6pFE#lp_WQbn-qVia z#Z)0VN(9~>a^{jPcn|j4ucW?nG-+;0tS$`7Q3$-ov7_#4UYAK*yMAlU;m~Vn=KzhS zNO7qnU-Po(V$-Wep6Qei^DUDJRwV7JG!@sTaMQ%RUz{06zBnM!7IRQ7tt;whr9EYZ=PgaW^AC=JL>`JD2&7di>!tmjpit8UxqXJZ}fM`+hbx@!t%5U-RK3coKn~ z+%An%T3i8x*Wl>aX4uKr+CR@k=u#;%Dk{7AgvD4M8BGlPnzzwhz)QqBT>8Yo&O6H! zO%ZKC)Mt+qOx|Wrai%MP*8&qUvrYRm=3o1=_&2-GhsHs>{Ftyu1C9qbaet*j9b^8T zP^k83x~S|r%>ZGbp-A81{=_7R=5~}~C7wAS+bXFAV59qN3iDyboxvSi_VV8C+dm3o z#8!`stGvUJt@u~uae>?Zn(^tHHe3E1j{BZ8jXx2}RKT2QrLg<-5$aA3tV$8{M!ObOS+GDbo7q66VTfRvj;*v-H0m1a25;>Pg1WdHF=DW7;epz)@EOWKiz~mWX1DGux(}94?D5g#)#yK@C%kj zqet}YUWnd~lTS5R$um~$xnVhvr5jpK>$^|Yk2{vvQ3+`luCe{(kpC1B+ie+YN>`*V zpcl$>ALpBkGN^FYdCGLAQzSyWL#!`5`_zd?2`jQ=b_9gdnc7v+{ZM$(P zx)9?KHMDht-?f%Q{nJU)KD@QdOmFSf`$Xkq<(ZO34aMoyG(4dED7?k-Ps{%L(w1ls z4Tn7bp>o&IG!Pw&qg)F91oOl!g!W~zkQ|(2$`pEIDL>)7b+)7E{`h@Zgu)PUR;6p1 zXtHNVdSnpM7$=gq#(ff)sj!?-23KX(aNpPy)_0GWKtHYdxS6V& z$#%$iJ*gA%o#Ctx=|q6Vs^Vw23)Wmdy1DTA5Fj|5+Z*NV>s@y(mR^%uKBODrY3&Gc zA)~Dm)!X?z$?c^{Ddz4|pA1SXH{6o|DTm}~`UVZFP<;4dGCDj`A({CM6S2Vj%C%J1 za)a%YT5p|$CVdb6_p{VM+Xzb%xyeD%Cd#IY2y0yngcC^Z^M-|VJdX5XN^*6_Ym>*1 zcbm&vf|h;(wn5DH^XjI%&p7skN#{%sdoJO9-oA1x35vB4*0tHlvSVQ!Sk`LhsbeQ& zntf-P<5aJ&s1>7PtW|KiQZQ7?Vo)gnyM7Rw93k$0!PvQKc#t!gy~6zYrhuVK_sn}W zwP>NoD{yt~F~g;0_6|CGBiHRyOAKG1P?-aJsutLXR4B?^OG}%m_EcIoiYL*KkJUjd zt&60n@N)#7f;yShmdS_XC*!-(0!LT7SA<7O#k-3;msfg#2hTBn&9TU|r!C-1Rbjj? zkkMAq4}+jMm7m-(GPPl1VLAbn>KUB3tI{2~?Li=tMU3lafPq5LMa6kr$aLT;P)6^p zotEivnB(h$k^bN@t1|r6-k!u;E5RiyzKdG7Tk{z2W1mnVOG*aLf#%}yZ_TLb$x>!| zRakkA!0Mn&_?9xmQCG;!L;C|4rD0B5R|%z2@W|V8;VLhCj;45UUjONmTErx`!u(NM zX@n)e#D`)t8i$gJ?{Zaj!pU6R*Ra#GEd8@b^>p80xQaR<1>*%J5nv51IYmXNg+)P0 z^Z;h7^ZR82=$Xy6t_W>IvdLNgjFA$WOyzxsEA5k##XWo5*ony-y)|YreQ|j%GT&)_ zAz4bsHMnp0#)nqiPm0n|8q01EC3Y%qD{iR%>RBeqUh3ZYGAb?Uu6$<5G1koWzJ{8X zkwI-NmI^cH@QW0`d$UQJBr)GM!hkxVVVW!=-SK#ej_HHZO4^%UKH_+#0`u$OFlQg@NkMrWXSm z!;BcGNZ6xA_Z7H#cU4E-^T%ON!H#wkQm+eXA@Wk-G)(QrQ2LYTxB&J%R4Y_h-Xb3E z&}kCI8OwGI>^#p|Q!nUXlBOomPTjnDB~sbVSIw?`NB zQ`9;}rzVPrwrHON$Wr_JkL>$})#H{~7f*y!p29d-OS`+iytKJa6j@FU^BcZ}yEGKv@F+AZ zv=WjnalZS{Bmlw#VfaK@nc?w-!qO@=^mg=cnOQuLl|v1^8KrXZtOar?*~G(<*UD;+ z$jVCO`M<7SXC4FVeuuH%+)fdm+=@MDi5wS;n=Ea(#2a|KU*!OvezHpS({uW0FWqMR z$D+5KAv8Hl_X5WSM??JkIOji9eiM6W{C1@c7*W|1zBg0i(OhQ~K{F6<=pUZr#M@90 zGlA|71dK>OL23#4T| zW+5YIQA@9#`Uh|Ng?NZcanjvxM}V*L7eNriNjxoLle06Zle=D|?U99oQt+|tmhBN= zJXt?jpF$AI^(jIQdP3yu6JklBe{!dRP1&`k0OFb~QbwHSsAak|D}BAX^A>uF3S2gM zuqu31Sc6|EM%V55V1u(m1fi=2dXl4<97IR9DIP3mY2B5ms87L7Q{U zUVFd~J*Y*Q6wC>q9_Re^PPzgTl&5r{A(agf(&+17-M}m{430D;sH<`@k!p!?aJDV7 zX_7Yp4gY_gKL#=!4qiPt4bcNU&H2~)#{q>K*Ut9=raFKJ%>O!n^A8d2clW^moc|BO z-~9X^9{yIL|IgLto%XxG4f~#{!(x7w`!KOj%~>);{-dejnlnXHzw-tYBfrRoZ5)xX zFsn*P=6PF98LRzllBFewO$-j(a9OMFR(ED_kBexJrd1h&yHz>idl5^9<;AKqTfTlB zI8o=jkFxMW=$}3@Z8RCaGiRC!5WzXkQC&J$dRQ8ZcdimtTV&P=x>48^_BQ-I> zEV;!1DS5I_%;dWFJ9>17s1`)_Zt?&t z$Aswc#ntI9JVqr15igGe0lBd;(K8b{kL@WP2~_KpgYW!@_@}njGnI0A%=MXf4zv; zDyeB|CW!i?rp><5N;6)v&O1tK?!R_Ttdqt$Lpg30lu#QI2h4!P+yDnG&qqZ>=}Rj zgfdH2rBLB{K5)dQt2K#}p85hP0Yp0zi0(`l3Or-i8xrGmXv*&i?-_^K7Or$q4em?# zvt`DScSDA3-SBksa)1rpjVA2~zGNfsWSQOa*BgA3`X2t+I?WArdzGSoHsTbG7Ep5) zC$vRD&3NNnENcXu{Z`Ow3vmtL_Z}XG_*rUAH||EOhPRi$5FoBy}@5IvE?6b5O7=k?Z&D&jGv`R{W9U=%_EP zZR_VD4%FrYI$THjfq&%jQqHwD$Ens-XVCR9%|zf(dlmG1Sb-grlk;~ca!Vdp^qONFLTf5t&AFg$g10a2 zt_`*}j;D8|7`#9$C8=h z4(0a1&mrOnYr0?oVC_%U+!2;XQ+t_%YZjS;eP$xTmV)m7es7$}Ut2&>Q>ZBm_5H?S z=Xh2*#`JlXzI`3R)BMzcJWFnq5+4$pOmYWsRUD~*8ta2rlVD6qHq3%2_ZQ&$SF{&E zp(|-<(xOx=tx8RKb)2K2u{{Y$opihtKhZ)t@&-FR&Xh1Mn#qVl!AXk+YY1LkO= zXZzOpOp3-4{K95A7M&V9wi%25;xm?N(@|AxY0DZzcWxEGfo#ft=r1%lRa2oN-A@!}95IJ8I-+yfykv3ss-elqu0Jfq)>t_B&Mp8~)FKxK9UXg)Okho)ge?SYdkn<7SF&sH^2 zehE3oYbZX$NJmLqL zl^h7?35mZ8=ybuk3dX{St*guU>WFD9?hXm*2V%-#Z1Y)FlCP^plA9yFE(>PDyVmzc z&IsmsBGzzfz;~e52z%VCZMmm3Xf$f#AOhFt$Mq0Q*`6N)#@w17gje4OP)wW(J6CZ` zpn1RtE&>jQJ)&p*Mnv$m5EdosD|BCQl5PBUMKbU`%oBo?){rK6R_5auS zh=@7JJDdQ)zV6-0&@!*w4ip#80kg4u{P=KKcesmTEL&BJtza6e$2px zffl$j@@=i1IB_Atcsg1pnY5seM2YZ*tL}zbq65-QvymX`L+v!XNAGcUq{p}zJD%wORJQr_K*RL(n zk@TS;7G8qc9SrsR6mg&M7mA=w91*i;2HnYYF+ zT2i3qu)4{m;4qw6FR2h~2X7Zl9tQcz2m3`Fl5x2+K^3Z5P$$0dx#g-AiO1WXg9yCy zq**J}Xu=verJ@?GAkSE2s_IZsdg`tzy4;rWH8a83EjE*RwRk|swe8@Hp5Mc7RU)~m zaztxm?vCb&} zOM-i#c!;yPORtDg2P*`XQcIvjWUWXL2Osf_rw_EH4NT zQa;L4R6H|INn)MBnspOV6*hNIS6}B^OUrmI>4fp5@W6#Rl_90Vg`!2`N0fOz+~fJe z_-C1~T{mabR5shqoP}G-+cc6oqYG zM-CwDF}~`GTF8cPmw$hYQ>+dn)OO-LA5qj-Hf6_eWpOb#BW7^7$6OXdRAivWHbQ)iEj$%n_N$sMi3I;2 z=T9}#GH-<7oQ}>ys!H(ReIcJV6orQ`Ad4BtFHTN2N&fS4Cbd^`bQ&c}SA~2M>adlO zdNPZ{)%NVhp5_yT0LR4*xY28i${OeCR1+MtG@<^*OvP%A@Wa^1gL{iMs3KNzoffzi z_qP6Imt9kYQ3yEU@Co~J#9vaOm^lG~aqIvX=4JuLV0jyOwKxLBw?bm1wUwP zPa}qPuSa4do{;19wRwe~wuc3$%j)#X$oi<9&dvgX9yy9cc8WFjkPqU{G#_TiU?-+y8Fzuw-#=HhdEm$|St;60 z!68upr7)5h>FP5k(!r@{4fJ4YG2_IMkBnIr5h1e5wJynJiXit2nwjD(9nEQ7e`W^K zpaBC}Po8&t2i%8KfH+NTlRpzdwa0o?L`2^a1OtKYjMe4+>~vKHnNS=;{;rKPNbm0r)H*S@f(W}7 zyuE0!gSz*Y^HlE!S^8pjwivc-hVrzpFlMn|>X!j3ZP9|ZM+J52my|S({wld5+eI3lJ z$JE~DIxtG0iztce-9q*+Ig(=%wZ*^~2B`3A=A zcgKl9uyW?kS9s)ryX01%K*hvT$w#OoyyX1OeBF=;bi2@E20lpsr?s(BLTblE_!tx$KjRc(dL4f{+%AA@?8=a z)e@f2vXO}nIlnPTCwgIlSbiscgwE~Af-!Jko#$U`X8RDoFx-U;QcisG@m0YuFJ!6F z%!3^kSXs#aKAxRmLbSj;k!ZLuGNC`_aMv|*WmT`LFn25+pcGnSk21^4LAyU--bc z^df&EPdSn{4A(;u5n;>aend8@f>V%w!fL>e-_fbr||VD=KYSXMs-aTP0)jb z+z4s%nVrEfamM1*p7bPEHO1OhWmtENqmm!qGYw}x>r3`JISHMC*WCOqMuVj+m}LH?y~CI_NPIDp zcDON{9wZq!B{hVPE@q@zxzV>=P<)gQah3A zRoO{S=IA4pr54z$oo5G2v&ub{&O_FScS}}@^8%(8&g=dZd@?gL>_C}^ExVbTRU+-( z>4rb06jQ3vA?|FTbmJ8fgmh7p7H)d7nL;J31z(Ap1^Q}+KJIn6L?K* zB2BAr@_H+YP5rviK3EO&o83Nys87I>Wm74I*1m=Ir}Z@_Q*!}vm?Wu}XC`7@K%ud& zTX@`LN|CmK^em|W(Cl&0>{fCylD&_-nWu?c$qu58{G{VR?Zj}j&-QEPtKG9=p)cbo z)UP?Om?!8wIe8^%e)nPu4q203e)d^hCo{4+@_;S?HSRV*H!MDG!s}BCSP<(tiZKcz zAz@6UkK+-NDpl9e+%Hv8R}b+PVHL_k--Z!c2tpb}s=>`6%)SsFw5j(;^ij#4YM~ck0#}G*}xf7UuwbZ0}V49=LIqEIDts zQh_sR1ejlwe?vU`u=hv6E6LVMGJc!yJ4L^$wp!x1j(UJ(?-Eyf=4k z;pj|ikvy%)5_1uC1-iB0+`@!TxGcj`Y%W4+X94?IvIyOHGQ6`A!Fejb^n+<~#^pdD zujVmozgY#8`zG5Y(gC6g{1@NiwxD_xDos#BZAt;9^hKDFXMco5*_2EMnno!hoF##o;c-irT;2u7C^~lf#l-byD zdK+<8nKf%E_IVY~&Z`ygG-o15DY})#d8b#re487=?>ZCkRIE(HGM9pxd{~pct((*E zooAtT+Kcp>Wz`UiQhg>x;T7fBeu6seYM37AOeFiDb9`Mi7@Wz>2VqHo4+@A^EAl{O z(_cZ!6GQE^iA*_}LQmHO3tK)+RGBM(tX}W7>I?r-JJs7`N9Q8$$;uk=v{{mzb4aOb z%gFw;2%P=8b4MKA{OD^A5U*M9cN-f(8$YuvLs*>^H(~qPteQJ&#D3W)Ge?=v*;~jV zo@5~#P3^7I6k+Ks=rBOBo+kP`hIxK5^kW5QlG%F1K=-y6Sk&WreJJO96>vN=vV*L- zVc?7=S-39SGeNnp79t_)4w!Eb0;Y3*gE<(tzDQk7_G8qftjN^AW;IMc<32V{LV@pg zOrynU>ho}C8_6;45qe)%rf}K$PJSAYA&l*Jm)>bEn7;8NfBQ~Zn6emppp^OH&gruD zy}SMtcTITs_|#1=f(t;A2|EGtaAcnbf)aE=8e$o?f^6)|c!!S6 zWZrrHW36~Pe*y`K{;fzpsoRBuepc$J%k1h;ru%~mkE&W5cl2Gs&+@=3p!6m-O%f?W3y@yPZ{N-B&&8N&hvNPY(t{x61i*?hW+b+cjH(lHo+cObVEOReZh{k6BFqx;)gU7G%~RbGl> zzJrD&RkWAPC44u7rK%++lI?knx)akHJJx(C%8u^TUdg#<@tS7G{iiOCWM-95=C$2e z>+3_b^-8rn#piloFm)|pw0385U7mibub^wnWvGn<`vMss~YpV+CeaH zQ+Ks7OK>$e;J18YAt>!#bLX>`-@$skiohsbYL1193bFnv3>{Rf~e4C ztumjSj+zZQjhee%eVoc}1CtD~Y@Lb{BVA zngH$_`HQR~CW)n$RyN+-&zfAGs#MZ{xw@Y-=g0uld4W(nJivVnSK8U%ZCS5&B+biq zeiE4}@oHIiX*{6AA?qrjT_nZSbU7K4DiibNUU*UkT*G>0UyLuDjWhn?c*}UE37goMt)uk3XPR1AntSK+4Q=mU7gnLxK}ns4h704wz-#_aenOV8s0 zAPy*~(1l0*sEw&RuTgk6^GuxUWzOuX@^|dRpNZ zT`zoXW8FSXR(I%pZIQZOu<>mTLnM9oOYCJed6(C3U<&q));Sx@j!s@q_pcM)iUyY_ z$Qn0(&hLG${SG1j^nMm$7U^QKOYE~u>FemHpnx>)aoV>&GFWuek+*UXH(;qj^gVZx z{Zdvd!@&1I!y=RK*^JL6y?Uybg_lwE<^%u;)r6c`a87iiww;!n&jR|wVypHb2WWwo z^|rJu7R5~pyM#rD*+pGf#(TW|K|J;lW`@sp>8taz)@H{RmajWzcZGa5~w%P*GoRQxKxfE5aB8 zGSr-@coN4j{>?2X%$o`3$+=ylKgKd2FC}@p>r5auk6OEjF|L{)KE~X;P!i?`(hgNTchTKO={i3fe zFQzjEi-WNXzV#;x>KSk93kPouHYacN(wGi&SVE%;u`jVE3RTutNj>{aehOR=o>Q$h zV*(4SXpSbT!qwqd61W|;`=n-`6THBa$IT5^RuG98(&>7PJdtWt{aEZl@0)uc8J;(E%0rl`BP*GKlkeu7C_h*} zbT=R5r}P2@gr#KrGPa}!$>d1Lg&~%emcJONua}=3&PgP<3msn`K;Dv&#(rG+mt-fk z>14oVw->GR=6#HbBc9gALr39|p)L?y@K^U?R*tVba{gqOkGiAPkEqX~%wg$le}RA3 zC-A1%SL@to?Ycfm56=Ak9(xhjHFYJEX5JZ1p|4PK$~<77{l+j?U!jf7%#_z3^88T) z@B>Zhj+zHl7V@>Ob(WD~x-Ar!1^FC3IgG@VthX6j$?t^OC78fIKFz!>@BeSmM=lcziS6$5B+dB+&t9v|oJl1`u3vsiT zOVL|9flekiI~eDIEXS^=bdRr{&zAHVE_58sJUpSpuJm4Om)0O5?u(NKpK#bt>N4u; zYm9<}9r8MURY6!ZE|spJ!9AlY*k!=EygVhTV@)*!_;MXg4i@6wn@h8b&T>co03$qj zs2IRDYGZNuDGSNQYEPr; zm!`iFOg=ES_H1cS*KIx38jL>!9rbkxHuvM)59UWgJ*@sE`71U~1F-L>t+96&Uv|~L zvUfJvhU(bY`IkheXkmMFu!WL@^iRxrFI%p;T-;|L;l123A6Dg!%gE2HbG5Ziy^rOpG1JPNui9~Q9}stJ@E;`Gq|pAdB6?U!U*r3Dxs+UFb~ScpcHi{+ zFE(BXe-*WA#m~z4jlOepQE{k!|7m1q&}PcM^B!y37chA`WT({Vh?AG;cHKdom8>JglC6LE4$-BvUO+S-#Oxur(Lwy0saiJ|~}ZBCpO zF}`sGUz%h(YFRAoTE>Op8-RDOY!y+UVHsy$3`h_=*ULFot5!zd9!bVdlMEuDsjbL!3--w$ew6($`i}(9sLag^jXi+IedG<6XHL`!<&+^p3`jx!|e{OIQ6Zd*XNvNmG?)U zsqg1>T19m7cnA$UhVsBqL==(-M~44|c?#+m-$~ps#F+q*U(eBP2^5fVWjie#S;_fH zP@sTt_43L~xC7g%qGdAIE`fqQ>*JUR52tw1V@E0=N30BeuEp=6Y$k02Q1%l!5M1qS zXFPK9Ns5DqYl_UAd?2s1p|pX{cYC7kvqsAwl6L^H%CVszmSr}%3W&~O`HZ`i8KGdR zoNFe8y6VvNFfg3Dg2E5s$Fa3>jjnSBZ1Y9P;09X3!Y)Ip(VsF`4bbo;M$34 zI1Q}7Vv_RvVNaQR=jo9vSQzO@^b(Lf3wz= zJTO+&1DnuiQ*%Z!gD8>pG(LYil2`u`;m@LIxeVap)DSFMlaJUqq9iBoz=Qu8#L2-C zV!E0*l*uFGoDw79WZAVVxqeq4NS=NmA)y+Y?fU_~Qk5j@JkNbRORA8z{?tg~a^QJ` z0jBKR>@$iv{?_|O-DBh1W8W$(wvC|H1CFw3HWQY^>66Z6&{Pu&bB3J{fIWdNejs$d zu4PSq%q6 zPW#C!e%MVBP2-zj?0M^>uE$mmq-!$q~ z%ImZk)smFFI=WTDpHNPp<=gkB)_hwLRDg_A%G7tR*H(O&^xB~K)9Zy`by1jMSjX0= z!^G%2{UJ_k;WXVz#SiN10_$!;Hyw!sZi2dgoA~jIs;|VO6LA*)r`*m92!Pz2|L?E= z(;iEzqeQJYx|@6|Hr&4?vv=z+oCl$kds0rO;T`B&W-^Zo1*2`g3Jq~JlzNJuz4rP& zL9qYs@GB1!!TFTsF&&wi6L$p)o}?FzZ(E$5#{j9cR?4WMV^pwx;|G$$KfSeSX4+L! zRf9w%q&G~x;l#~om>brkK3Y<5P88Drt?*n~e@9!D3!;)cgHUO;>u}T$aPOGmSOC9MdY!H}0&hFZAl0Q}}n9VHvkS9Ycsp z<^#vXvmlY;qaYPP?)8T=Nb(mun5cD1Iys$PJEyd>QQ9N6Go+4@2Y@VbDQiS@kFr}p zY|>p7w57JA96 zd6Drfhdz`t>dRuyWx;s;ev}VpoSvV51B8hynL zn8Y1B8t`5rHo<|1LISJP_x)@8=JwK<(B1;uMK6calJsl^%<5u|EbJ2*69p-rNQjG{ zRvIG;sZ`v%#=G=2OJxLBHu3;<(0@rzFJk3GEf+=Ix&MRN`(L~M^SHG^@@nKAtrX%WXLc=HvbSo1|yjx30do8is1Ked~T zG_jKniJmdd@pGNf&7Hk5d2H2?yW&AB*{*GmWNwpxUcY6La>2%hV(&9JSV%~Bm%nhR zEr1ykPuMZy43;h67*3<}@AXQH|IyxD(zmLcAIg*syU-q_9IY||=kK@=P?ES?I(N5) zLg{6m&Sgat)?BOj8kxVR$;dd-B&x=`wB6%S__W*m`H2)7o@OH`ql?KZhr_(+H{SD= z?kx>8`sspW4kpw**=Sd%ojcmsit9$2`-&KQUY2*f{CO$i*)ve9w5S!zRowc$c<6Ir zq}>U4--r;W^NTl5fgLrv7(AWqgu)NF%E)@-Tiv`D30}l?FweD^J-aA*?1iuL>})Gw zW79VpO%4-y$SPl``8tk_RX%JoN*%zqVHx~fe*RyQ@Wh)WqO_#@&vOP~;boyG@m)^5 zAxZv{rc(|Cm6XD1A-zQpw(M2xEmvs`h(A7jc8LE1{&d=Y`gONMqZ8+A)Ge>zsTrGD z&N(UovNGu!I%4|RjAUkv+_zM(Kp#A#^{llBKKFW#>;Clnj$ghB(4^=(wST(*y^EY$ znpG)4gyS*H89 zFEK`cC(y#CdwWi<@m$~Cot`xosYXEu%mUyv{2(|^3PUL}YrCPcJ|#*QaVF&T=;;6} zW5(5xWs<$XP}u4vlGYM%2&KNBddiT>lxPxXKDD5ym^QYaR%ei7vGZ~ER%akrtE!WW zWl8giV@f%}s+bU-R_I4nh@9$&+(gm(uMp81RuGt!CGtyq#^{leA z_kFa}Y+PV1l2cEp&)%}^>whdX}n*U_U>gIa>| zfk+qw>p_XV3x)cuF4|(+UtFo7E9N7$v9pCSEyeR>9phrxfhrMm#@CMKN~+#nvWsS1 zUlykUy$*#p9UaVEyB|Q05D_E4GKSx<>4UxOp7rkfSYVSrLtOEcx!xn)Zgyf$W>Ld4 z%A7_j5(p44i$l{U##@?Tg727s+~trBUmZtv=i2$UtsDa3a!vU8EH+-O$1bJvCVw&B z!g03-Yxcoje`${t5X5<10HgOBpvJ72k99tWM>kus!UelNZ4ZQ_D)WNj zdcK1<8mg@O;c*dHO&G@;oU@fS18D*~DB9;;D?-_LDTaLi9>oYMI7*dA>uRZ+6kYFm@-+sT=r9Ix^| z;tKH;s-3p-T#hjDWZ{+z#0WYbhS8pxitd)vZ5{}`WO7x6o{#k%YR5TLx)8(^Q_ak? zwFXK#qV?4CLyjf_Rg{iMgQcH_KmC)+haHkn5~YHo*ezE?j(mh950_3~nlX_wRmmtz z<|gw*yK8N_k7;iwfac@_`q~frirNW^4yNB=A3h%$k!~{q;qai= z7ZLO{>w=7wvoNcqKK978Txf{cJ-yPhY`wC%GL__LZvu8(IUTIWJwbM|H*1!@)aX#( zawK{wks*&s?b4=xHg7KbcmwDeBS;o5Pj9Z6z1%dLVBG75R=u^xJfzZZwc1X;FRN2Z zf7%|SZ27dk2~dnyr@JI8O(bd^DNm0oZ!qh~_uX^ZHg49OffS@Uo8Axt>jQOn9XA<# zDHq1J)h-qugk$Pzqb;o~EBX;|JzGN=cDQogd3IS|(y9@Mb^s>uhFSGeDgHO2C+2F1 zD>-%kx6HpJuQ&`v%U_Y}5KlU#FeU-fk4(o?{w=uY@D=E{ftsannJRy}!67X4tMV zd0uxOudm{p;wU99SNyU83WoGWP6EaY*m*c zW`W#qxYt#iilTgip@wR;GEdK+Mb_eCBWjpF3ryC`0ySYp`xH6XrDW_>k8=_ZledTY z%H}+YqQL1>PzfroouVsMX)4;ly4HVqEbqs(bC^w=27{|myAZX9J4aIx>GDz z|5#aS99P2{EaU7l&=y4}2swcdB!oYtx4#bt_eL!{(e4w_0Vu6a({NioHV6-IO`oO3 zFSkhU!^|73$s&zT&#BI;t=E&fZg zBxoKw74QR>?ae)>h^`+dPz$em~Uiox{+{25BRgv z-d)K3OERW*YJbcoB0qAYM6|wxm#Pe3u@|u`IlAbhNQl+0bZorf zgtS?Tx}pAL09KGAX}NMev+@L08?(G%5t7ez?8L_u+cFm_(o~#njdeJrZ+RTsp=jcl zABf&G@rjZPI8%%HdRRH543VjePrT0F;ajNC?;d{1SjRz_evIrEeeby`n>QvkPM_=K z>VEB;n|8Eg{FG*0W@&ZS?0facOq<<4Bda=SQ9$Wqnbo#+nX2>+j`=X8cre)FJIQ}; z17jC(i&qm&1)-PO`;RUQ`1M;KckZi#eM7gh%R0*zsIC5gzkX(o7E;BG-_UNKe@}i- z!tiG_{y*go=TU2I{v)mng&6OG*txp7EV4CU(#Ke2>l}RsqE+zg%#4!g6C+<4vRm~? zG_EBCDWd19f9W3GGG2UFOJ-J6;@hT^$u=kEit*Ybu|X1RIVk9V=#*@cjRMOz50pY~ zXH72GzP0f;zUv^p1umHi2$5YErU;B(H}QH4TuT#noLQI|%KN4j`ZQ|w+0k##4 zSwZbtF+J0v<7mvK*;(rHrOz*@H99&Q|f(*HX7|oz57>u(@uptvRx% z=+k0opU4BRqW;>xZzsQhFNh9{Ifz}HKF$R7(Rywc>cUXsKFI^_+10PVvZ)^_Ygbw4 zJ^7tIpVV^n$!uYXKx5YGcTZ62Bj$d?l#EkS>+Fw+!vRLyq8Zq88`HR_Qcv=i{bBYd zMwq0#;6?)nCS}p5x-sh7CI&R7B)8a+uaIYNi7e%3P6^k+lR;(UnJn=#i;u!`TtJmj z_1+^{Ti0q7PlBZ{T#u+Tr3CbL-go9-?rKF>yUshJV0%O|23Nwh&&l9-Ml{>tDch++ zTln|dWY06o-STy)To>oE8oJDWzVV z+14hE&X2b9d_gv@i1`@-N^$&oH`*L?E=3UH+RC!rzR9OR+DFR#QAS?E)RvkU zqDqR$$fRj>mG`OeFOhl%**yGJtChpE;Un~O`*l-g11u%BqR| zj!Cacl#vF;*$q^>eTkjI^;9W5rGt4qQ(wsl`fY8s@LPH52z^x*!USRVXdb{FpmapN zRS{efEetZaw)l0ynO*Y9wlsp^+M?OfRRruwJt|g9eQY=UZ8q24Ds{MExQJ&cR ze{2IyhkB=CkmXhJ(h|*K6C(|v&JC>XdyS=6c0Xa>Ae7L11;rM>*&on#qomLq{ zY_9z~Cg(t><~nWw(WzyqKk1DH@2rSXKe9?TjhpYQ zb!2zMCqIw%OzZ2|ikWFY0v~6OYB;FON~4ZaUEb>$|NHFF@UiZED@637mLA#FHbp5Y_dEmAANoo6YR>HpO~fcspP&fqi1!wpy~U44xA1@fF&YE zxKR1s+Q6PbPa~ZGM)E;eW4Q1Z9lH80(bk=~cOKoH4doR`!`%&zUlj{VKdLNG>?h{@ zYMI@YHVo^8!;&?f^)mG5ZCmulET2N49Q^WSnRn`!p=^YB-98bG1jObPnPSxT#{LVX z`V&@wh~Obn?~a&w-eSFq&5pNtyzIe=WO>;DZouqFJ*rt>JItApA$x1zOo){h>}{-R z9@uJ_SFRfxxA|3>+fWl;vh+QKcukC}PZW4oo+~d}(I@exWwj%x>LwVKf6y5r=FD0^%iLnTw1V9D)7e=LWwsfp zvOx})h*EhgFb!*x$p4ncRZMT?wXd&Mx=j`oU7_!v%nIGwl=x z)W4IlB^xDrP57jsp5(muJbsn^`DESe`z-=>as86KhYN+Wu5feLpoGSwi=8}N?@P*k>XmG*o-*pLXP!v&zZ-} zPzb3C!O#2)KJLe-!K>y3zQxau2+HX_fIM_EuBso&%vBo~<}u9N9WX31+pW~|zR*Ub z{NRMK$+R+DgvS2DHO;j9f|Rp4DzhfVgJzpuS z>HS0g09B>AMQQFL>*2Gh1+R$z8)MG72;G!m{+EQd-Jy8eDQdMr-FKP?AsyDFmO|wM ze4K6|ZUo~r){GIDI~D!szk_#2#ZgIla8w8_H~ z;NTk}DB1p3SBJ&7CqK0b%=uB`kJ_6nwkLYiA1SZj54OB|AtE zB&60Il&YO}E8=A2g%9aceQm)ca6)Sr*b;CI1Ws&QmV9zbZ7|Z&aDM^@8_g8`uE$Uh zWNhnEi5mzg8@Am71G=N)o3vt_Np2#ZsL8pJF) zAA?I*ud92{pA(Fx4j#hbrvpsFT(9;D&myFSoEyYb&X zF0>{*l`P_@(o9}3STS8Ce?XH4CslO*3l^PP#Y)i!Ha??)v6>j0pxolTIEP_9jt1+V zlP7{LMWwBZk_;W*`B-TKc$Up9p3c3k$Bp6Ed=sg9ifJojvKN)M`d_=Tp~|s6 z)d!&uG{z=~hT(e-krPfbx9@fBiCiI2HPTs)0_cNQtwlD#+X0sD@+vnQt$rc$LKL91 zz%e*Not!1+7}9aF{V&PP5zD%eddLT2D{uYN@6`sphu%3HoaR8+T$?9;>1*!uYVB|H zU@nC=X2eG9?Cq`oD6e+#VsVsaf(xW#uZ93A4A2+6)sco*tR1<|cDYPy@x)LI`Dl|> zK>@8(e{^wqF)*&W$##^c$STa-*AbOM)D9jKIl+pZQU4MsbLy%uYY3z1M+_q^ASYO( ziF6{J_v9#F@>spbQN_1Zn4=dmb7|Qvlom7LX(jPBmRnK^;%qa=AiE=uh z5_JB!D!B%Vu&)8uqXBcT534y*Lfgz@9Od+ym+IbSptmiBJlrXN0e3QBg?@eS^wn|# zoE|i6s^4^$0BE26H*wBCxNf&SWMe>i<*?pl+*(l6T!nsa=>)eVWigO>(FBZ}eAA7< zRPfnov^02@04vLE&bUT^?yT&7hGA5$#lf==e#jZsld114*n7rU?=C0z(4HXeS{wG{ z(tqR%sB39lnY;2~S&C~?OBl4?(cb&Z4z+EGRY>+|2c!iEvZ(HepWo|te}TpO7UiL` zL5=2~4IH4RiEo;5lCz^Oig8V|uod?g8j6wn#$i0XYznn%e}-8Xi7d-2N3^xqRTUK$ ze*|EO#6TbO{)~*4jXlkpx;jMG_TS~(Kb0Id4IBr8(`(s8w-1>>#aP7DeD;XW-G(M*(9wRWz^OV?d)H4vD6<{*A6rNMqAcAD>L zf8iWw0k_ma3nnr)L^~_Iza6azkwRW#`^QyOSApc8!ofi-%U_y9sL@1CmEl+u-6!16g}JXNWm)vPa@ejG&Ww8{-Iu&!Fja zn~h(tc7w>=htiGdE($`D3h12o%ciIood@MH{xgoeP+^D#g`I_wD{8l0KL@=(IEt|7%HOB*iV;m_pUIP05+ zSKO7ySISRpC=SU_U<^FOuiT43ZTO+ecaTPXbEMeW_lJ<37HresyDa z^D*7)WMu-eeiHvzpWP$b3qQMOC|PNr{kbF0zHG9ZRsjiP^`QmQPIbRR!PVp{)AySd z(RTvQPbfNcO&yw)={cG?TR1%X0(=Z#eQC`O7M{+Ephw?Lb26}$3bG>Ojk;P#*=_YSFxG_DE68hMYiliFino9K$Dv2JJOWfa z<0xx7D9jX&XP22Z)oqnpbhjkq_>!~^bp^ErtT1?q)R0j8xiF?W7X8+S3VY!!|0S3v z?D1$-#AJL+Vd8%ongfHfQ{bduWHojluR~{pItV7DVuG)1e>X(e4jNM+Q7Xve;!LxBAr{%X%11PxVji=J^b!v;*qMw`q*<-ef9n-8Wb9GbuxRps@y zh+1zR-yTEYnvk`rnfX{5h04q0&rP3`hU<@0Sv@|sBd*nVpUz&0XN{(>vz7toOm5ZS zXkf^_l)7ojqk6HkdeiIFd@JqNI;GYUegL zJpV`D^S&R>mvdd`I_pDbuE}I&tuH?otmW!PX~7msjEOu3-B9Vu3D7 zui%?JaF?08%j(7Lo|?47oSIo1R?sU0gR92X+OsSR>`W_@8tBQS?HL4W1N6ZYH4iWB z@-7~Ca)CS2$#p{C>tT?pUiunsk(Aex*!***)dq5IcQKXDBj47U*7s^!W0-AImikYP z$%Xl4QGgrx4tw-aN#rF4>=AypvBOoSFo4pGiNDl6M7yb4A~m)%s2E~;9+w8a z^(b9XBSlFT+nOaZyOOn*()xc2r&nvq-s{Wo74%t4wY zF(q>^vBcAT&e0@|;UqrsB$@DrK4qU!!_03Lkxy(>>Pu=vp7X9Zdw{*gJUl%-&-V*m z-goi?tPobGcb`Au;Y|VWNFq+jqH0$kg@zBcrc?J*SEeLci4P+weERpamR_Qog6>@x z>fq|QEbo1UOyRK6Jl&+Ym%Cs)6=m4FL9UwmkJjCGuQ`j(GE)}J>!!|IE>59=&NXsd z>NGA-U#Q8SMXU&=cdVP%fr!)1T^H5!D|%U%Mspx*f{u9%%L9;-aH@+~h~-&AFX@gD zOT^>p1tr$8p~Cb=!U+kX%Pg3++O8OHoy9a03}{ zO=aB?7gLHspaC>;nQNi@ujpBg#sZAT#1zM--vlaL^Gw;)Cj>#gKTX1RsAgWqxZM-t_uRj4OtgfIypQ6f`IxA+PU$78($ zs9J?TvMBS!uq$EpO#GxF-%M=Y~`P6QN02+w(LENw8f_uO!pA>{3 zPiF_fmd0)c&2GuWi375I`p*^_tAND!PnT8g%TR$7jcTke&NTq^78cAn2AX^6mKwjO z>Vm}hQoXK1d+lcWiH7bnjT&f1G2`NHz92ToyoRpn?Vk&dd+HPTXbH?Sn;o4zWk?^TkZ#P3H#aZ)R%#K3SuOiaGU!~2NtnvQ@u)!ay^U(~s7%WA^q zUbU<5PP3VP-d-FJR@ZY6`qkji@t18|NuxwHCXmqbGgVN|5k+RZ#33^%s z-}(I$lJ%%CmsM;-srNBJAYt)H`f180<@Dur6w|jHz zL`8VUJN8A@igZTsqVez4kIMQI4*BcgRr}9*bA+}BJcTIX69ljMV7Xi!$@J`67Io6H z7i1C>NMALJoz5M}ABP8JO7>Ak)_#82C>6(BVHC!AVaDiVkz{fJi8|zyW`E~_D*a5& zTy)S<{RF;u;Nx%V-2E2o6uE3s{hDb)OCL)6c6bN+{AXzO&a~|~c49RMJWQzBFPpm3 zUB5y!x+z8s-p=cdti8lYN)2CX4`k2I_GU?~BoksYO(INPP>@tOr5`eqBiOH5%;dAt zyI~3Y-FIy@9+Y+D0G%_YqhecN^9F1BLE_5<`~oU!dmCMRj4wsKwK-Zz&9srdD(K!@ zY@72Y-0}X@re9qJhYOFz9&f$q@w7=#>GWJyeiiI_b|nF`TKgW+j~S`DX|=$Ftw}Pp zP#x`fJ<@4>34Z1{rW^z25sKRWg0cyYYJ^VzlKR2^X$o1%C2>U4U-93)4@ z@lsCIL{f8&UhQ^@Emq*Na}X+RzC7i3+fx^u(0wr#WJz(-1V>1IbqQn&=N^omA{&m8 zHMg^?;S!onE`<_V;JlJs;=7BDI+lus6O~V&Ru{QEIZ&8V)d54?JvTSv$KCagQrGh- znCd7?o1qb(6=cPKLpNjS`K}A0gkqz%PD6?ZQ9=tQkCsTvN8!eTlZy-w)7Vf%6K8%iXB8h6F%dm3Y40qiJEl} zFk9h2O7(M%gU!jR!R_dK+&NkgsMm~q`H+{~g$@mLRM{2LQM$|>Y?_6(#ik^Y1t?fczgQdQs6#_~o-*(&M>cPsXu|iI z<&3}H5A4%ExROgelrH^&cMqiYcJ~2ybzdL#teL!yYTrm0aiY~QJ;@JJcnY@#o zm;3v}Ww=Y3?2pErY4KyC7E1^H`r?(fDl`t$Rn^$?$KU9@9G6~$;`hVm)d->)PaOG* zPnf9gZBtu^q-0WM8~&SKvg1LC zt&tfufuP!@F(nzD90p3IGl>J%@iQT(2lvz15TiNUMiIF(nWPt5lfuG6o$x`(H8o(qO5O6 zHx2Ek0y&nI_peWeNIF#6C!P5;CRc`MN1>3ahX7ZUPAcvA?W(D9G5coubE&8MGlj`t z2f+C81*ObNT67<9BcUU?B_p|%#VLjvb7hq&YovfygW9O{R7i1Fg?;^HXtBFVD@4wU zw1JfGUE-@R;_E+1F6;e&+uOpQa*K&D?Hb*lQ*iVlapZ%LY~omRf8bH)0W0cP;^;R2 z-P_`-Qw69M1+|!8W=~N;BG^PUky*E#7xk7~z9mu{&6#E^8-wDgn8Eh-ZC@XobZ$*` zeqf$5coAR4C84{_jb0*)Y!v{N1pcU)RVMOoT~Z+sge9#$536zE#JK6Ob*7ir-MMBl_d0RDN+Xn$dIPX2&(@1`4MzE^hqhmJP9d;WkR{$MFYj zuTZvz*AjW1#+2)k(`0>`Ak4;I){~2*7_72OpIi`kqgMUb!+E08AX+Ga%zKQE8V*p|?Hh1=8)wAascns81#*$8vV;7Za zRI4HzEhkj>U1fiJUC73%XdPS5;O%U|{o%Nn2vfykel9Q=>u<#XTPG$8pfY9^gbmnhN-$NknF7Nqs#DO@X~{XZfQa4AfeD4esIYUM(xrQLAAZbsVg7PTAb2Rm)SFzcV@2zx z)U;MINPW$q&B&PlqX*8&bGH*TaXa|<@VBV@=^2u=WpP)aS`65V4nP0O&!e5yQlGF+ zI0vK8RY!-l^K{(2Y(9<_Tk^iW z+rnjUAD?Wm1Az`+z)AuN0I-pALs?5Jc%e~fx9hnTO5LG5t?l(o1w6cfma_b^%rX-V z>vP zCj&WjU8EEnUPu8Lz*`{b2CwR0N@{v1J)~}@g0Y-0>@sel5I@)BF{`lw*CBHSchltL zf*Tu$Q_b_}!FAGK89l#>3n({Jr%;jaGF>5+-`)T+n?d9z8uCm%@?(u%L$|c1-|^Va zbV>CeM}>R$&REC?bY^Ey*OO*5Ds3q&6UUj-Dw}unGw}O;f{jxUr}T9P$qxJj^wBX6 zvX%M_dGrf+qC`xhQYHrkP-GZh;ZgYg`H#O`iLbsKs(BXRhelQLot*H?#9Bi9n5=+- zrS_J@1KK-QCYFHCToggqmi5^h6oTZBh%-}H{yBy#fK+UEKZ~)8KypmX?Gb>H1SC@< zBAWjMP$xxN@d4}!NG^pVudy6Kx>P_+?56D_o{OzkORM3;=zTsBMbDD(aI5si3vasLM0`!q7oHc=j(>SJlK)cv2KMe7G5*LDUUs!zNS8PmJeAM* zUN%m0D#a7M)pGw8$`l(l`@nur7ow!3q)n26);2T^?Ma8i#0-sN3!Il&+tds23Qhgo zR89bF&Xw^;0IB{z-Tr@d^TZFC%kHT@n6dxKG3eWEa0qD@n2T!|Q!9E?RT(Mfx zPD{lQ3R{^pg>IL5rPu|7#B@bQmB&)KpDD<_^gLvE-nvmc`0UnStq{aX>_0zkGwv5Z zH(4%NUYuv$4!%wB*vkR`sV+S1bN6A2Z{Uz$D&xI%vumV4_-UHy`#>)>3yUPOuSm{Y z0--CVy%wMEWwo1`Yj5u=iavrYEVB8TiBFk{0Y}ye1%=l?!MlXQ!}px)~ZGuPF2%nO^EE_0*t%z*p`{T z{dIk8Yyc20{h}N?lFdvUFB}p(B{j`Zds6fmkl{VHO|!;#g|^e z@)J+0GhPu}0nz=qL(^pwBsvtI$FB|W&#F8mOPVHX-i0+zp4qeR<;qw$v5c&ZoCJD( zH0`JBmSpRZ5U9E(uXK(~{E}^lcC}irqSh$_2R~nkQHUK;wE*UyOyTJaX~i365$Tz!eGL6IG-v<&`s zD`n3=BRq5t^ZOjcvbSKMx5*^48|dm{P<+Xeop=`sT@$E8_wY$KTG0^ZwwPSsv=tuw zy1M?r)zw6O<6Wn7F){vbpV-!@F+p(6xj&~MO?W76B)C&`7jTn!v3+@E_u(PcM$=Ec zH};wTILU|eCtl`HJmx#GJO4Ca@%CG%h01~33EqpA`9~Smw^^{G)w-qc2ib2Kk+Vk z4kwOJ{!#rz<>2K{yd!{4V|^3#k=FVDUTM}p`-%NWKmQ+ot?`d0H6;Y>wRFi|);Isp zAKz`?xJjp$V(|X=?rr|hS&may{=iu`bMXIT|1|o?|J@vu<@~j6)HiC!?DLzyD@}e4 zxA0C?3;a&Z-=Xp5*MI_D0|s;>Kp0|=x9msWvPKa1uhF?KI~=-mX#Dp8kJ(xO>+<~9 zHFS_NiRjg&Pc49)(#fdwx(I7ux4VF?TNXywk0Z_gr_N^O*lMIT5N}J=K|Nv4l0P5G zO-9Ne$0QSF6}wH5qM!G;{DI6WJ*0}E;diG(P7J7g7|aGTwmm_SHCk3jlepjEQU3eENfZyz z8Zt2Oh$d$yh5q6>Tm$wqziBy^74q7u_$D%A%CfpDu6k;O6WvFq0`)bu!8ASEJNA~% zx%_0*io0Cv^UiWgGODr42FNEzF#`jWw=wA5($f~QJuY-YUewN)O)3&%QG-f%DkXlK zO~fdfJjd@Ht8SX#BOyOriXVNE3+JF5d;*90SFI#=58B^UZ0ib?Vy8!tRu+YW?J$QfFq04Y#MllsJoA zBuX)oM?=uD_y}+Q$}%10Eyz4#?uDgX96ul9`1-YhxMULCzBpDuo$Y~*U{{-vTY?Vs z2y^{XHc}+BF_&}1KUONR{Y9ZxzG@s)Of?M04*?$o<8*?hh^{R?j_%83cEH9Tjak}x z%MOTC9T$Sk5BE9tGq-uqOXIG8J7Yi~MYfyjcGXUQ;ypPYcT0t_s#Go8h_dUQC)79j zf{c{ZJq%*o7DEm)S!|`zHMT{wYMYRxAsClPW@^5l-rd!Dh`1P{K))QBP**VYe$H9N z(#QWA2Fb|0;c|2D`%gS%v}3@{^U8$_n0Cg(S$;mO?s&y#;Jdg?ty+~;g>I;eiC&8h zn~eh(Y_~6h&n=NGTbark5O~Cr#9JuTmNfPy7{t-wG9#IDr72|z z={JtvJB2BFi|?}8H}{TMcLg!lrLWEWl-D5tgA;p6eiW% z`%IPQH&~g8&P;DW!e*ofhB4?ukxs*mAie7JN*K3TJ$$xk041`wr5w4Cra7Pfq*9sW z{Np`6kw|QlexDzN&TQL8y|w(~GHLPvf$i~=9w&*gP6=L|A!ZO>2S0w6Xfb3#{7iS^ z>$7~r*T;+lfzNHajz$@$ zzFu0EUi`YcbKLb))v=RXJB{jbx8i#8EBNV4BSXkczYZ|yxRHp2&HSVR63ukcPMs|f ze?<02Jh8Sx+L+Sh{;R!G8dKzH844Veof~moDi*h=7!TbL6sb<3lFD@HeLjS3ZmwI} zGW@1Jt)`Id{7o7D@H~wrUM+LOX~fT8LnGHVF>_Ta|0wxN^-lX26J~&DlxVX`nRHp{ zpM+wG#_1|$^IMRBciZYlxNrR~7zr>P^_$@C<#-t4Kz$E z6*|GF;M|pJ(~^Z)kPZ7>cR7T@Czkd|@IRWJYpIre=`3iqvJj zH}1Y86OuMPz5MdH&rqqC1glYvq&ixV-LTP-u^V@~uPucJ&6e+|z%MO*vc=Rr=`n^rU>;Y&Ix8zx z^=(oFk3CJz)dN**Vb;WjQp2Fs3CmUjo`cyg{>Fx`&nZ{rg?iL`Ix1(i9~Sqb|41XJ zRl|xi1nrPk`UgHux3$(tDlv*yz$E(c5K54J2$(1`A+zV9`hzI9$>50>fI zTsxl+nwQG$t7dInR=Vzvoz^Mc^lIb>%ndlmv$}CbVKiUs#BV>z{addQ+t8YjA32#e z35kWD#I6`38GhX<2N*9A-8u)OSJuHZct7Hmfh>d+ZCqq(p%py16=f`WKj7Bt2}zrRJ}*#;qPSn#+MG&}AX=di4271Eb}3qqDkj znC20c$to`CJbYERJnc2+l9cm$Ycsmk;!0E($u=3*mkM5#;HsKyzNOvH-?A0oO>^8{ zry~kHITA9=sIj$aRz2_uSRb$vp~_G2;judmk3F4rjIrD+o_Xn}Y9%r$1{U*6Wk$J! z@9EPrlF>xzY)VTMm2qvX^cjH+p>+*Prwr1~asefuwVa=Z0N6)(@mc2&oZ)fwOL7{g zzSPG{(nO~jAcJw^3_Z`l=yD-U|0Jw}@_|)tW&LV*u2pv-LOWV-a!$p2e(7q>ezo_9 zys1l9{!rC^cLIM+%~;~*{=G78?KfsU$@CB$L-|gK?$J5r+mh~`|NTFp8+`nvjVxCbZ-P2XQX449_~QMEPjB1& zyph4r$C9I>8-rtpr5q0s^9HvgV^*qo|MFA(YZdc5ukbqkWid}0X`0j$NVG%#g%rKq zDy=@^2qbl!uoyqL>6n+&dAKI98^`qd)shUxdE1i~(F=P{Iv)Jo%%u!5d$m|Y`38Sk ze5}!Ot}%Slw`M#=nha+sI)_yXU8$A&p4Wp?ug?bc)dt1#)>w77?Xn-%FiG<~bB9Lq ztB=2zs>$Ir@+@;4VV?ZM$KJtxNIc(Du&J8SjpD3s&4l%0Ja2oa@{j3ugxaTljiAuR z3OV-6ck`&p_ZB-gTTdxf;-yJ)7y~_Y9gVh!AEkWFjZLso)wLr1h%7V8G&BWiMeq))UzOMx}P+X*eKgsx;CP23DEx?AN5?sboNNkP2QZLTRA zCd4KL46#5;l9LTzO1Q88GDS<7ZV(vB?+lmpuo)vr)SF<8 z`K~qQoL(OuU#2+Wk|ETqlVGZ5nfPP5fe)NyYp6TpOlq5|*FRctSozmnhHCQD2tRKr zuMLNShwFH+{&nerEvcA~fj|Q9j#=^ri2NLOx{)}kpS-bsy$PpL1dO#$l``1uuW{@j z9=?LLE2$2IQ(C}1lH%K3DD-<_^|4xtKk-&s%i!`yuOph@PLh$;BovB_P-rHnQB<0< z7M^ZQU&n$OE<+a#h}As|@?q|1pLs?(YZMadUiHs^@Uskg%wA-T!DhdWgF<8vwKLgl`U@Lkt_;g{>- zbMDNPN%^uwzD_i{N%0d8kxJ<>yV7|zdAy*#2L)S+AV`%BN>U2Tni3V{DXKJ+<9MyN zigT(zNo_rwVwN&ve2Ev}BN6QWds|OU9AWR7H*Wq#8M{qFeQ5(kp1mezFW(Wg{;t^2 zb15@9EeGn@eyl!#oBuMMe3NvPGriqBeYR|jbKp=%RyLPGky`PoK^O#7&y90aJIcs( zot~~*Z?QGK!+V^vZ<6So*sBkq_5yN`0j`!`S;x0{mH_o5!2f9ZD?|0yajDP$*f200 zC!aj$Z==e)CJu<^;ZTTdmsO@cb6R_Mq-`7-g4zXvm>V`=_bnYUF zUul})-kW$%5{#U@7IqZ($-a~t)+;XN2yZBENHKH|%CZ@|8!kBuH>mR!0&-ofn{R!J zzeGA~Jc&dzi-(7`#xpbK?&L!Sr6z0x7Ow)5vt;HY_660nj{L3EhO4JVJj9}MoF)h; zUzp$U&Lh2}URbxntzOnbm1DD+nUxH6AX>Sv;}PTw%CVMlJZTZ#F2MSc{+x#8Xiog8 zov!JmLwRvu>5YHYAMMKZq%6`Vl+=cknj^CU@}Z~kp+hsNUP7GjE7oUw(z%j$g|WS?l&I zvo{G+Rv@+cKcz%ej!OH7jF!o6p5JNUe%JJ(>Dqn!+1=s5(aX#;&2Tpp?IO|4;+I$f zg|gjba5x_o@=_ouHp}z`=6vE0V(zswbP7?(_7|#C?Im{%ZCuk#-%4ZZVP}PTLk%@q zrqi@TXD%f?<{(llkEwp+eT9ZHwRb3E?&&5UrE!c)ylp*yV6ien{k1~XF1b~ixS*Gv zxsTpF3f#n~fV1hH)@6P)vuy}(Mmj1xpxCu^H??Xb#SS%{bj_I`4~WFg%o82eFf5+G z;#JErVbBJi$IHt;+O*IPPyA@(NfqneSbO+^9l8rK%Gi}l;raf|Ndodz)#ov)X20Po*!0}khR4_!qZ@%|Id=y5<$#L=#F4%af7no?D0iIVO*=~kkR0^{@ z;FJfJ^ES@osqw*hcz3p$uz1cagmjcK-zR>pZDtB$?|?XkAgOdoX_vrb-yD5OM#uQi zwFpe*KA^#g&b@siGh$uASxei7xs9cb0j0rYM5<%tnZ2`qVL_cytrb4$a?bkWtRH>H=N{OMQ_c8 zT->APtMsIc1h{Fu>-sw;$wwp%jdrSDYZJ35wO2fO|NQaP@N##x)AjInZf^%tUVc_9 z&_i1r{wOp0brwW-Vy2Z%lbSVK`L(+yQl3qdmcg4T)~>C?Gi-8CXp!VgwDYGQQ~0A* z-A=xYuPTL7KkQWE3zIih2vO{Nk`cDf=h?TJW?bDoeE__K?3!C1d_G5v>ENae+8h^u zToG&L)IIG`XK)H<>h)|x^sd1kq+$zUm-iF4pIX|xf~m3A;2P7kJ=M|PH$GTcEc1eL z(WlwXcZc22mIMy7nHqk_iS1;DX3p)mH?~zNa2Ix;v209IAVRn~;C>htpWr!baQ!BQ z(A(HuYvC^lq8Hs}s}7gSbqPkOWU^s`EGMe?**|(Ruk69(T@;CQG@ZN=?h?n$8qv|_ zZaTc{DTd(3qKBK?i~J%zA=DqD3~4voqWDaDxU9t-c*~sd>9^Tl>OKCvIKoYanCWgm zr1qfnAGb02{B}xO3H;ksdkFrl3=?XMzD@X9tikz5sKa5xjr&4T7v!>ZFPVGkgxt8v z8-O$h4$WmKfx?dS7&rR}lq=t}xyj9moPV44`e z{K16YGFg?N@#QKg+b*$gg`+;jy8bx)YOP}~C|Rny7gJVKS<`z&X>ax=xH(ENa6lDi zmooZnBtJ?xD9x%3r&D=8<#Frh^Eki;muZtf3D2MOsusiciG(D3C6cJXa1a~dBTXh2 zGYeCe8OkkuqNNHDTEA7z%`50)D{|iPuVm)BPXK{4lL|c0q$QB&k`v-6V$Uj{dT!rQ zS{hFI!hHxb#@9+>{FnBNkLG^gMiOO-M;P}itcT0iSk1d(ECIrv-SxM}3@1rFzej!@ zCmk2KTG{}}57OXtS$u|k?xM*zpB=JZYuW$9(^_>iJKRm_7TXu}EWO=gz^J4lZ2>+0|V0b1?q6WCk71 zQIdLi&G>Ws8 zmp%>e%+jmV!~;2ad~EG+2W=opim90f5x)J|HJA74sf54F28l!s^5 zxR0J1UzF*vRN*ba&z(F#q$DH+Ur<2?)gLfz&q~5@>&0KE^SZn@)Vh#GLWF8XTtb*C z?CGt4UmIE8Q3;8qrUpYrL`tMu@5jx46nO4bpd-jPWT{lu6ZecsY^;-?K87x-JE9(l zvNCcP@(UDaopK+k-mS{7D_sI&zuzBt31WD6A!||YuFm&>a2g4Gfs(IjIo^eA`*9{> zvZcZ$)RhB&s;4v!9UZZZ*3&njLFlnYWr4;eD9#gjvM${%2b38^50X&q0a(Onq-Z2_iP% zRd>+g;<>%F7_&J=^TTg7E3|`74KN1DUelVWv0CQN<}sj&&gj zicy%T+}$qbGq@;anb!+_lZt@Me=Q}q@{8tl5yMo4%yu@+_%yJV^cV6*P@X4A6wd8J zv^-O54DKDiX=;j_Um}{+9~mv}AlCrNBi-DY#eX-X_8V&ujH0Nw)9!R+vl_Sc>|f=) znTMw~9UEMRXO%Vjn_ag{J$}TvrDz|$kIah=K)q(<8S)Q!d-<^uY#yL}pyF4v`oj+o z?-%F!zstSNN8oT-SeClT(#VW8@DOw0l8dKjGIj+bOR+rheZQrnec0vkYg8lR7>36g z%X-xmqDCT`9S$kr*DKH?{I9PsZVj}x|13SGig=^&3OiKDo!s;ayT+=Qn|r5&WGENK z^{@^;eT7G;Wr<*x-;{~1JV{Ikm>-+)qeqNmflyv6%GN-bKIpb>hjZlk>2=7vAg_=O0sfD38I&efWkj>JdWhb$Jp=uQbBtBX zQJA=^pxv8HaQqs;W&#%=lr7ASq)lPu{fbacC-YbFVnqcjc-irCiOpYrTB#arlqUFo z3m7XQ=C|~Lw|^XoWp2qaYne#|eA{P{EUw&bi38Fp^IA*TN5)TR zq|nd@nMo~38KEmg?ucVBG9wGLhRX$ern5G@cf9;<#_#v-%3IVfVW5Y)+t%hZa_+Tr zUgK`{Rxc{O-F1MR4E=iIbDhYTD$3jg$jN5M53AoQ4&lA-4R6;mZf84jFV3%sG)tLD zVzJO>w<_Y5tN3DR%uBGIvIV)zne5jWOkaEWg2?VLQDt+tszy*$m=)?=W&2&6V~Z>b zt%fyj&?cZ{eUUc_u5JJHdCWcmomepAPH-cOa_#)kS5M?`e_m)^Q8Wp$M{{0XWeP=1u@Z`Jsu-*YgMr+)~)g7&{(z45d`QEo#Ef zhtUwdZnao|YYdJo94*;YzLvo;LXCs^IV81Su#Ff7Aq@3#q&ghKh3vK{%8a>{nqJ7@ zhdne0kEjeSX4g@?7Nv~nJhXaCBl-z$n^eT9W>W2X>WM@cD`N&vrWthZ_Up-XNDHLysNg^aI*j)t9#A~q z5IJE}n`kQo$ADwEM=}w@?Y(f+=X_F@-T~FRG7`PR-0~lbBR}!1G?;3{EHj;pyrhtM zTksu}oh_vw_t;s=S$gEIvcNjrO8qFKLY@2_rmPDj{OmKM;Tv7XmxWBI^8~_$;id+p z`Sf%mMyJ$nw7MZ9Bzs1EYnl{FNxJIDCFPREJ~n2#3Aks3Ss$|C)V9`yrQ99)UfJDP z(YK-RDkrY`-L(#VpMob;LR9CPs*Jh#=FO{&1W_ll2QjbOLMUIngej$L3Y-)=G#j$x zNt^8qLQe=gH*igvI|Ip%xz=YcW#T>@9(vA^Dk-Pj4(wya#z^p&)8dI@EtKK-)>yHY zi-We0`EkFa(RhPG!NfSaVQV@#$C*;I*cJSjQftubcRA|MkWbtSwUEkbY@@$ET8Wi; z|MXGcvFT!Y=k!!xDJckJ0Mf0lthF|vpqQ9iJA1QKFBfo!+Gnm`RzBfEGq(d-SA$)I zP2Vd~`RQhqGY?DMXLtrGDI3AmAA5Cj+;K}<4CUK-vCvOAn z$4rJCRcyD^mEk&~VL<+n`vx?t`YJ!|iT*3$_&Jo0?zF}JRg^3zve>n}79XW{bym?Sre)+~$ zAZdiYdB?!Mj#idkKnzA74_R1wB{Pid(1qIPK%>pLbd*A1^0Xod{Za z)nBNZI1fAVoA6jYu-nI2tqV|Gc8Vnz#oF?h)e3yOCFqZw=eukafajNKwER%KGj0EI zU1MVOcgUYmqA$gcU|qmOOx_DzDw@HK2khkeWQj13&NqMP_`w%uTkL?u%~$1PhYN%K zq|Ah{SMtUWO1=a!ySqm#Dp62W5X43x$a@h|OBt^D-Il>amd1ffA%E2SN;Q3KKSj8f zGS@t43X;0IqByxun0}i~+;|;w&Lt4LWr_Co_IRgk5UXLq+vqPOYLsN3EOs5N-jA5u zOAEPfl3I!A8^Owt_7n+bbtaRvq+0l086Kv6@POoe=cyN zP}Hfi*Tq&Oul#x{{s^`h$T>Tq)ENupiJw*YEZvE(Oo27Jx@Y(&w2(4S>Gh5JmRQVB zycbA$I65(N$g*`#DN`dpHX~DH>qZysJzaJiKj_B$H+RPA@dKXU;-%P8O)&ktUaR{5 zzCfQKlG|(e#`j+P{`)6%q4Rq&xh=sbFMq zdHaW>NUv6aDAFWJ1UmWyUsVq9K>`vqa{q8d`aKE-#5(_(&yW;s{kKyM16MKDJ(A>6 z!}viOPlI_Q$XJ+}_3qvr(e+&1HKsnb)Wc|xnH z37NEh9zQKX@BaoO>l`fXd)+7vaK%vlbMgY9;F89gCERb=U0$Dps+PybHM8;qGzo2{ zHM`}$oln)Xiy2B~gJZuAGp~Puj2FVta3yxAYpD>rD~7I}h{+RM<5^g-zOB|2C9Z2W zz2HOFO`mmsD6y3Ml+W@f-m_ZgYC-7zwd-)iV^FxQ!dQVYFGG)gBkRe_mtC+;Ki%dx zmq}zl{^s5NE38jHNUY=&v7(4p4@>;}(Lo#4sp09$I6ljf3=4|g%g{^#a<{wG+sptP zF4t~!KaAySq_kx+612Q4oFZWdO5aQM5PYdK=4OiySOlSzoxapplq@=Wt!?(`qD#`f z{Haz0y7Q7(`69VMBTE@E)8S%LmJ9FFMb&Xv^IW}T-tlZ@qJOJv#bv>j`B4tY*Y)tt z+R^A^^lw`#PG|RRXQnE5PTY5T(KC*04prgn)%H7-?~62(k)a+#mdlqM&ckamgzcqt z${Iy0l5-p0z?A+#PEWEO7TH`KUP2Q6ckL}phv_)uieUAeQ9M0Fk|{BbL0Z`Jmi3lm zMA|O%RSGJdBPG2mBSz0z9T|+uwsUiQt&}^8Wi&xtA+!MMZNM1?%J)yd&&#U;kk zv=8`6kG3@AIyr3LZE4ie_dMp#znpORi3bZ{noBP)ZD2OMuA#){jiZ{<5qpWq)+&vl z1zRyB8ufl8TByW+-&Vyspbc9{=dzQ(gI#@S*pPdACDfESs>wS`g1@yUl)G0Pi0P(x)l{eO-oI>>*IMZ0@rZWdjtTiycu_L{NX<40Gt58%7vB$1ghcZNKG>Ows(U4bA5@cFcb9jJy$3@k1 zrxE?D{c?@ByK}lPFK1GYzPQFlm_Gu&u#ripblYslyEigu^{>1A|6kkRdj^lEBVA<- zNaq~%?FjUUIi7G+h0U(%)6$xmfE|i5!or?wKd$a_${o;pA^~NK7ppEj>v?Z-T>{@< z(Yw3-xD#-P!|yN0NC_4@ANBIp@K60%#k0^5g3^{WN=YtBG0ERn1=y#8u*k zK~Q4?!dgWAFvp_+_k^!^K0pS)1r>06RhvoqjvSnpjXIiq^KO=wF1dPw@?d+zAx?SK z`DS-(^o&1iqH<@Bv^-13X0y`p%V-r#i=nD)==7ZOs)}b%QVDa{JRb~AVV|5Rt4XI` zsF{03r^T<745aY$#++;cw8h-P#^#h*gFXU5XDCGTiE*zH*5II+fd!N|dAMAp`#B*S zq;f=?%VJ*cC=pFSzpTP3xZt(QoXaI&rOc=tAGbNqE|4kv%R$4q0bS$UGLAi_%WeGL zBnrIf@9xT-{>ixYU-P57%6I$ugvd!+4u73lY=ZcqY1|%~MJGAodD_7n_HB-DRV3xwBO&_PvQD0hk zFqG-n_1-n#4_Sblj?LkJSb4@{rwBi|L|$bw_ewAaxZ&<~D@;p^(O5;#0308gF^VR! z*v7a^qAY(?cT=i;$a>;t`?orTd7b&t%A`!E>kTW*_fph{*1fb%or+2kVyaq)-}Mj! z4)^wZz6{6hzey0QH^^yTU#133!j=-jmalSZ77|ufN&x}#D;abqv@-RDxNPI4MN3Ll|p5uR!!n(*gg+OjwVx2m#c$A9tlSCkG}agFMi_n^K8(`%Kg`f z{bTnYnN>qE*iQWAi_4&!f&wPc(iYx_8qg^Wkd>E_YX1m!R7g_{v$ z$pnpKaIriH(|f>j1m}9gkJd~so7pgfCRef2T;<#KShEi^!yynWYIs3?DlC?)8cj~z zjq2Iq=kutA{um8ji+fJi^AnGkC7daxhUo9kEilUox=VJTC#hhkOSZJzZ<9loc*>OB z`F!3DrzDFn6jZ+*>^CM5#5q_Gk=w>*Ho)$r`Usj6ude*-%#H5Bwr?|vEz29aN|=W| zQ&7eBHhlOF^G}7C&O=q*^O=hZ%8ed>iDa(D;di+|FH`XQ6woG+hh>cP9=XpdT+!z- zp2Ebs%+~ETeI-$PDGAB1V0&)ZL91 zPLFe!3H$#dJJN-@)9$<^UXyaBkl#5sHq+)-jJkp4POsk~Rd}{z*Z1C74(;CGeLJ1X zAAJ#(^|uo(|DUTg)tzn$G;yQjR#bWpM8r5!M%x`-i#PT!J1QQ0tG^A`Ku6%%-Na^u zx4s#)M~q#XP%2b6RGSzX?Dfok#Chhwa#9zvYWdBJKtE_~%rrIgy}WGPjH7f}ajpZi zkW!F}yxaOrr8mM!OH<1|L1aagbBZQ0?9d|_%wL^vzg=f^*$moPj*4hU;*LlhP^(c_(biE%zK)vBKA`bT(_f>dnbwTm=*{q)`*Yf- zB}#Qy_1LR@TtJ=U4YHG^PW~Z&kCOqJhHm$vH5&QDQIk2}DAYFiM)*eJvt)Z(RsXTd zcEilJ5P%6w@Dd)R+g)fu(I!^o7AG3c4a}%~bXV|KCRrTcr;4e7QjI*~W}Th$^$c1) zF1@k!Ub~8@IzT)8f{ZxB)Ux`yLtUI4>6!deoj#SYqv(a?S4*{ZxVIHM?(&xOMhy85 z*C701dClNyxpfpg&MmYG_VT*2_+{EPzB=mbwz5F?M%Wm2g_p5JW`1Mad&d(zynkpI z{A>FBXZw^#a`GaoBwJlG)u=&1x%d%6DWxCYpFQAq(p+0{V;Dv7d4yG)^jKJ3R6!w- z0Y1hCi!C2Kw{ZqE(q5j?-C6>#2Qn%V1nMCm0z{OrUZC~WPwHGJnNA~hNa_aQPL2+^ zt!gn+$h@8PEK~6Ir==?ptZYaUdotg8UzH|E@)8%9g=;l^AKYrg(^nolCQ?#3ahNb^ zo&NP0mKu%1$0+YNE>S5bfN8S@3YDb!Vsn(k3+d#U`XT_b8>wK0bw)nvuUAAv3%=dm z3!dFKouK1j5Wp|Rs4vTSsd&qHbbmu-7@Ih8=;lF}g2 zlAP4m#bGfAdE3cP&9Fcc_f;(Rq{&zw()iqM>iE%)xDF5YmKQ2VuGbknK}Y zz!{I}(hLk9w0EBG!E*}*tr9A~M6g;p@=89h7{h%*u_jPQnM(F5h zWKFHN@6a3WF!R9N_J}A%pV!;;RD<7jf_oO)k$%HJ2*n8l-5*dIUY@lUXD&zX!FZz zkywy~~eQN!;RX%Y~8sp;-P3WclJHZ%%9A6S2p&2a9r0;%qt7>J#Oc zkra7JHjf#YFw=@n_sa2J|5ulOeBQS~0j|joo-LZ+gu`KOQ?9Qf@6s zX2U0rqp*~~%tbE7MJYlWIfCh8$Cz*yQ1*`QQ)hcN3{ad?DV+Npr_tDzsNc8gRN=r04OWd1D zGfBDNxCl2wf#W{&1dq)*baZwVFoH?ve zayrlCI?sSl8#Golf+Kjdtare%FwqBCM~6m)u`o2C0DWtoY_5{2X=3%jJNC;A#XHT# zZ;;tKu+jAqIF(?0GOl$O6^rfTRh+sDYDza<^|If3dhyN~KJz^Ii1XW$C;_Ykf{)4E zYizBUur&u6(0ysMXviRJ=$h|zAh-5Ss1i(-PuMHLB-j{?49@C5bJ!@d@9CUZi%@0B zuFQXlkmRYJ*+h7d{OFa1b*tFG^Ae(@C|tTA$EP*^yom=Z%MjifE4GnU^4!{ F2>=fag!cdd literal 0 HcmV?d00001 diff --git a/docs/subscribe/phone.md b/docs/subscribe/phone.md index 602a0d45..a022b2a7 100644 --- a/docs/subscribe/phone.md +++ b/docs/subscribe/phone.md @@ -102,6 +102,23 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app. It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor. +## Publishing messages +_Supported on:_ :material-android: + +The Android app allows you to **publish messages directly from the app**, without needing to use curl or any other +tool. When enabled in the settings (Settings → General → Show message bar), a **message bar** appears at the bottom +of the topic view (it's enabled by default). You can type a message and tap the send button to publish it instantly. +If the message bar is disabled, you can tap the floating action button (FAB) at the bottom right instead. + +For more options, tap the expand button next to the send button to open the full **publish dialog**. The dialog lets +you compose a full notification with all available options, including title, tags, priority, click URL, email +forwarding, delayed delivery, attachments, Markdown formatting, and phone calls. + +

    + + +
    + ## Share to topic _Supported on:_ :material-android: @@ -135,6 +152,67 @@ or to simply directly link to a topic from a mobile website. | `ntfy:///?display=` | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. | | `ntfy:///?secure=false` | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) | +## Advanced settings + +### Custom headers +_Supported on:_ :material-android: + +If your ntfy server is behind an **authenticated proxy or tunnel** (e.g., Cloudflare Access, Tailscale Funnel, or +a reverse proxy with basic auth), you can configure custom HTTP headers that will be sent with every request to +that server. You could set headers such as `Authorization`, `CF-Access-Client-Id`, or any other headers required by +your setup. To add custom headers, go to **Settings → Advanced → Custom headers**. + +
    + + +
    + +!!! warning + If you have a user configured for a server, you cannot add an `Authorization` header for that server, as ntfy + sets this header automatically. Similarly, if you have a custom `Authorization` header, you cannot add a user + for that server. + +### Manage certificates +_Supported on:_ :material-android: + +If you're running a self-hosted ntfy server with a **self-signed certificate** or need to use **mutual TLS (mTLS)** +for client authentication, you can manage certificates in the app settings. + +Go to **Settings → Advanced → Manage certificates** to: + +- **Add trusted certificates**: Import a server certificate (PEM format) to trust when connecting to your ntfy server. + This is useful for self-signed certificates that are not trusted by the Android system. +- **Add client certificates**: Import a client certificate (PKCS#12 format) for mutual TLS authentication. This + certificate will be presented to the server when connecting. + +When you subscribe to a topic on a server with an untrusted certificate, the app will show a security warning and +allow you to review and trust the certificate. + +
    + + +
    + +### Language +_Supported on:_ :material-android: + +The Android app supports many languages and uses the **system language by default**. If you'd like to use the app in +a different language than your system, you can override it in **Settings → General → Language**. + +
    + + + + +
    + +The app currently supports over 30 languages, including English, German, French, Spanish, Chinese, Japanese, and many +more. Languages with more than 80% of strings translated are shown in the language picker. + +!!! tip "Help translate ntfy" + If you'd like to help translate ntfy into your language or improve existing translations, please visit the + [ntfy Weblate project](https://hosted.weblate.org/projects/ntfy/). Contributions are very welcome! + ## Integrations ### UnifiedPush @@ -168,10 +246,13 @@ Here's an example using [MacroDroid](https://play.google.com/store/apps/details? and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch broadcasts is supported: -
    +
    +
    + +
    @@ -239,3 +320,29 @@ The following intent extras are supported when for the intent with the `io.hecke | `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** | | `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) | | `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | + +## Troubleshooting + +### Connection error dialog +_Supported on:_ :material-android: + +If the app has trouble connecting to a ntfy server, a **warning icon** will appear in the app bar. Tapping it opens +the **connection error dialog**, which shows detailed information about the connection problem and helps you diagnose +the issue. + +
    + + +
    + +Common connection errors include: + +| Error | Description | +|-------|-------------| +| Connection refused | The server may be down or the address may be incorrect | +| WebSocket not supported | The server may not support WebSocket connections, or a proxy is blocking them | +| Not authorized (401/403) | Username/password may be incorrect, or access credentials have expired | +| Certificate not trusted | The server is using a self-signed certificate (see [Manage certificates](#manage-certificates)) | + +If you're having persistent connection issues, you can also check the app logs under **Settings → Advanced → Record logs** +and share them for debugging. diff --git a/tools/shrink-png.sh b/tools/shrink-png.sh new file mode 100755 index 00000000..b64ae53b --- /dev/null +++ b/tools/shrink-png.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# +# Shrinks PNG files to a max height of 1200px +# Usage: ./shrink-png.sh file1.png file2.png ... +# + +MAX_HEIGHT=1200 + +if [ $# -eq 0 ]; then + echo "Usage: $0 file1.png file2.png ..." + exit 1 +fi + +for file in "$@"; do + if [ ! -f "$file" ]; then + echo "File not found: $file" + continue + fi + + height=$(identify -format "%h" "$file") + if [ "$height" -gt "$MAX_HEIGHT" ]; then + echo "Shrinking $file (${height}px -> ${MAX_HEIGHT}px)" + convert "$file" -resize "x${MAX_HEIGHT}" "$file" + else + echo "Skipping $file (${height}px <= ${MAX_HEIGHT}px)" + fi +done From 77872f1b6a7ad29a01ee588e72360c4e2852d0bb Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 20 Jan 2026 15:54:46 -0500 Subject: [PATCH 129/548] Remove feature not released notice --- docs/publish.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index d2f027a6..9e5a7503 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3503,9 +3503,6 @@ Here's an example with a custom message, tags and a priority: ## Updating + deleting notifications _Supported on:_ :material-android: :material-firefox: -!!! info - **This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later. - You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. From b9844f48f176a0f0687faaed0559ed5287224366 Mon Sep 17 00:00:00 2001 From: Shjosan Date: Wed, 21 Jan 2026 22:40:49 +0100 Subject: [PATCH 130/548] Translated using Weblate (Swedish) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sv/ --- web/public/static/langs/sv.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/sv.json b/web/public/static/langs/sv.json index 8dc5c928..abcf94d6 100644 --- a/web/public/static/langs/sv.json +++ b/web/public/static/langs/sv.json @@ -403,5 +403,7 @@ "web_push_subscription_expiring_body": "Öppna ntfy för att fortsätta ta emot notifikationer", "web_push_unknown_notification_body": "Du kan behöva uppdatera ntfy genom att öppna webbappen", "prefs_notifications_web_push_disabled_description": "Meddelanden tas emot när webbappen körs (via WebSocket)", - "web_push_unknown_notification_title": "Okänd notifikation mottagen från server" + "web_push_unknown_notification_title": "Okänd notifikation mottagen från server", + "account_basics_cannot_edit_or_delete_provisioned_user": "En provisionerad användare kan inte redigeras eller tas bort", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Kan inte redigera eller ta bort en provisionerad token" } From 64309c01015e4cf2ba025cb6a72ff6aa365a1105 Mon Sep 17 00:00:00 2001 From: jonnysemon Date: Fri, 23 Jan 2026 19:55:04 +0100 Subject: [PATCH 131/548] Translated using Weblate (Arabic) Currently translated at 88.9% (362 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ar/ --- web/public/static/langs/ar.json | 146 ++++++++++++++++---------------- 1 file changed, 75 insertions(+), 71 deletions(-) diff --git a/web/public/static/langs/ar.json b/web/public/static/langs/ar.json index 158fa926..d0c1e4b6 100644 --- a/web/public/static/langs/ar.json +++ b/web/public/static/langs/ar.json @@ -1,18 +1,18 @@ { "action_bar_logo_alt": "شعار ntfy", "action_bar_settings": "اﻹعدادات", - "action_bar_clear_notifications": "محو كافة الإشعارات", + "action_bar_clear_notifications": "امحُ كل الإشعارات", "action_bar_unsubscribe": "إلغاء الاشتراك", "message_bar_show_dialog": "إظهار مربع حوار النشر", "message_bar_publish": "نشر الرسالة", - "nav_topics_title": "المواضيع التي تم الاشتراك فيها", - "nav_button_all_notifications": "كافة الإشعارات", + "nav_topics_title": "المواضيع المشترك فيها", + "nav_button_all_notifications": "كل الإشعارات", "nav_button_settings": "اﻹعدادات", "nav_button_documentation": "الدليل", "nav_button_publish_message": "نشر الإشعار", "nav_button_subscribe": "اشترك في الموضوع", "nav_button_connecting": "جارٍ الاتصال", - "alert_notification_permission_required_title": "تم تعطيل الإشعارات", + "alert_notification_permission_required_title": "عُطّلت الإشعارات", "alert_notification_permission_required_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.", "notifications_list": "قائمة الإشعارات", "notifications_list_item": "إشعار", @@ -21,7 +21,7 @@ "notifications_priority_x": "الأولوية {{priority}}", "notifications_new_indicator": "إشعار جديد", "notifications_attachment_image": "صورة مرفقة", - "notifications_attachment_copy_url_button": "نسخ عنوان URL", + "notifications_attachment_copy_url_button": "انسخ عنوان URL", "notifications_attachment_open_title": "انتقل إلى {{url}}", "notifications_attachment_link_expires": "تنتهي صلاحية الرابط {{date}}", "notifications_attachment_link_expired": "انتهت صلاحية رابط التنزيل", @@ -30,23 +30,23 @@ "notifications_attachment_file_audio": "ملف صوتي", "notifications_attachment_file_app": "ملف تطبيق Android", "notifications_attachment_file_document": "وثيقة أخرى", - "notifications_click_copy_url_button": "نسخ الرابط", + "notifications_click_copy_url_button": "انسخ الرابط", "notifications_click_open_button": "فتح الرابط", "notifications_actions_open_url_title": "انتقل إلى {{url}}", "notifications_actions_not_supported": "هذا الإجراء غير مدعوم في تطبيق الويب", "action_bar_send_test_notification": "إرسال إشعار للاختبار", - "action_bar_show_menu": "عرض القائمة", + "action_bar_show_menu": "اعرض القائمة", "message_bar_type_message": "اكتب رسالة هنا", "alert_not_supported_title": "الإشعارات غير مدعومة", "alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.", "message_bar_error_publishing": "خطأ خلال نشر الإشعار", "notifications_delete": "حذف", - "notifications_copied_to_clipboard": "تم نسخه إلى الحافظة", - "action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات", + "notifications_copied_to_clipboard": "نُسخ إلى الحافظة", + "action_bar_toggle_mute": "اكتم / ألغِ كتم الإشعارات", "action_bar_toggle_action_menu": "فتح/إغلاق قائمة الإجراءات", "alert_notification_permission_required_button": "امنح الآن", "notifications_attachment_open_button": "فتح المرفق", - "notifications_attachment_copy_url_title": "نسخ عنوان URL للمرفق إلى الحافظة", + "notifications_attachment_copy_url_title": "انسخ عنوان URL للمرفق إلى الحافظة", "notifications_click_copy_url_title": "انسخ رابط URL إلى الحافظة", "notifications_none_for_topic_title": "لم تتلق بعد أية إشعارات حول هذا الموضوع.", "notifications_none_for_any_title": "لم تتلق أية إشعارات.", @@ -60,7 +60,7 @@ "publish_dialog_priority_low": "أولوية منخفضة", "publish_dialog_priority_default": "الأولوية الافتراضية", "publish_dialog_priority_high": "أولوية عالية", - "publish_dialog_base_url_label": "الرابط التشعبي للخدمة", + "publish_dialog_base_url_label": "عنوان URL للخدمة", "publish_dialog_priority_max": "أولوية قصوى", "publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts", "publish_dialog_title_label": "العنوان", @@ -75,27 +75,27 @@ "publish_dialog_attach_label": "الرابط التشعبي URL للمرفق", "publish_dialog_filename_placeholder": "اسم ملف المرفق", "publish_dialog_delay_label": "تأخير", - "publish_dialog_delay_reset": "إزالة تأخر التسليم", + "publish_dialog_delay_reset": "أزل تأخر التوصيل", "publish_dialog_chip_click_label": "انقر على عنوان URL", "publish_dialog_chip_email_label": "إعادة التوجيه إلى البريد الإلكتروني", "publish_dialog_chip_attach_file_label": "إرفاق ملف محلي", "publish_dialog_chip_topic_label": "تغيير الموضوع", - "publish_dialog_button_cancel_sending": "إلغاء الإرسال", + "publish_dialog_button_cancel_sending": "ألغِ الإرسال", "publish_dialog_button_send": "أرسل", "publish_dialog_checkbox_publish_another": "نشر آخر", "publish_dialog_attached_file_title": "الملف المرفق:", "publish_dialog_attached_file_filename_placeholder": "اسم الملف المرفق", - "publish_dialog_attached_file_remove": "إزالة الملف المرفق", + "publish_dialog_attached_file_remove": "أزل الملف المرفق", "publish_dialog_drop_file_here": "قم بإسقاط ملف هنا", "emoji_picker_search_placeholder": "البحث عن رمز تعبيري", - "emoji_picker_search_clear": "مسح البحث", + "emoji_picker_search_clear": "امحُ البحث", "subscribe_dialog_subscribe_title": "الإشتراك في الموضوع", "subscribe_dialog_subscribe_use_another_label": "استخدام خادم آخر", "subscribe_dialog_subscribe_base_url_label": "الرابط التشعبي URL للخدمة", "subscribe_dialog_subscribe_button_subscribe": "اشترِك", "subscribe_dialog_login_title": "تسجيل الدخول مطلوب", "subscribe_dialog_login_username_label": "اسم المستخدم، على سبيل المثال phil", - "subscribe_dialog_login_password_label": "كلمة المرور", + "subscribe_dialog_login_password_label": "كلمة السر", "subscribe_dialog_login_button_login": "الولوج", "subscribe_dialog_error_user_anonymous": "مجهول", "prefs_notifications_title": "الإشعارات", @@ -107,9 +107,9 @@ "prefs_notifications_delete_after_three_hours": "بعد ثلاث ساعات", "prefs_notifications_delete_after_one_day": "بعد يوم واحد", "prefs_notifications_delete_after_one_month": "بعد شهر واحد", - "prefs_notifications_delete_after_never_description": "لا يتم حذف الإشعارات تلقائيا مطلقا", - "prefs_notifications_delete_after_one_week_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد", - "prefs_notifications_delete_after_one_month_description": "يتم حذف الإشعارات تلقائيا بعد شهر واحد", + "prefs_notifications_delete_after_never_description": "لا تُحذف الإشعارات تلقائيًا مطلقًا", + "prefs_notifications_delete_after_one_week_description": "تُحذف الإشعارات تلقائيًا بعد أسبوع واحد", + "prefs_notifications_delete_after_one_month_description": "تُحذف الإشعارات تلقائيًا بعد شهر واحد", "prefs_users_table": "قائمة المستخدمين", "prefs_users_edit_button": "تعديل المستخدم", "prefs_users_table_user_header": "المستخدم", @@ -127,76 +127,76 @@ "priority_max": "قصوى", "error_boundary_title": "أوه لا ، لقد تحطم ntfy", "prefs_users_delete_button": "حذف المستخدم", - "prefs_users_add_button": "إضافة مستخدم", + "prefs_users_add_button": "أضف مستخدم", "prefs_notifications_min_priority_any": "مهما كانت الأولوية", "prefs_notifications_delete_after_one_week": "بعد أسبوع واحد", - "prefs_notifications_delete_after_three_hours_description": "يتم حذف الإشعارات تلقائيا بعد ثلاث ساعات", - "prefs_notifications_delete_after_one_day_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد", + "prefs_notifications_delete_after_three_hours_description": "تُحذف الإشعارات تلقائيًا بعد ثلاث ساعات", + "prefs_notifications_delete_after_one_day_description": "تُحذف الإشعارات تلقائيًا بعد يوم واحد", "prefs_users_title": "إدارة المستخدمين", - "prefs_users_dialog_title_add": "إضافة مستخدم", + "prefs_users_dialog_title_add": "أضف مستخدم", "prefs_users_dialog_title_edit": "تعديل المستخدم", "prefs_users_dialog_base_url_label": "عنوان URL للخدمة، على سبيل المثال، https://ntfy.sh", - "publish_dialog_button_cancel": "إلغاء", + "publish_dialog_button_cancel": "ألغِ", "publish_dialog_message_published": "تم نشر الإشعار", - "prefs_users_dialog_password_label": "كلمة المرور", + "prefs_users_dialog_password_label": "كلمة السر", "publish_dialog_base_url_placeholder": "عنوان URL للخدمة، على سبيل المثال، https://example.com", "publish_dialog_progress_uploading": "جارٍ التحميل…", "publish_dialog_topic_label": "اسم الموضوع", "publish_dialog_topic_reset": "إعادة تعيين الموضوع", - "publish_dialog_email_reset": "إزالة إعادة توجيه البريد الإلكتروني", + "publish_dialog_email_reset": "أزل إعادة توجيه البريد الإلكتروني", "publish_dialog_email_placeholder": "عنوان لإعادة توجيه الإشعار إليه، على سبيل المثال phil@example.com", "publish_dialog_other_features": "ميزات أخرى:", "publish_dialog_chip_attach_url_label": "إرفاق ملف عن طريق عنوان URL", "subscribe_dialog_subscribe_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts", "prefs_notifications_sound_description_none": "لا تصدر الإشعارات أي صوت عند وصولها", - "publish_dialog_chip_delay_label": "تأخير التسليم", - "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.", - "subscribe_dialog_subscribe_button_cancel": "إلغاء", - "common_back": "الرجوع", + "publish_dialog_chip_delay_label": "تأخير التوصيل", + "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة سر. الرجاء إدخال اسم المستخدم وكلمة السر للاشتراك.", + "subscribe_dialog_subscribe_button_cancel": "ألغِ", + "common_back": "ارجع", "prefs_notifications_sound_play": "تشغيل الصوت المحدد", "prefs_notifications_min_priority_title": "أولوية دنيا", "prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط", "notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.", "publish_dialog_click_label": "الرابط التشعبي URL للنقر", - "publish_dialog_tags_placeholder": "قائمة علامات مفصولة بفواصل، على سبيل المثال تحذير, srv1-backup", + "publish_dialog_tags_placeholder": "قائمة العلامات مفصولة بفواصل، على سبيل المثال: تحذير، srv1-backup", "publish_dialog_attach_placeholder": "إرفاق ملف بعنوان URL ، على سبيل المثال https://f-droid.org/F-Droid.apk", - "publish_dialog_attach_reset": "إزالة عنوان URL للمرفق", + "publish_dialog_attach_reset": "أزل عنوان URL للمرفق", "subscribe_dialog_error_user_not_authorized": "المستخدم {{username}} غير مصرح به", - "common_save": "حفظ", - "common_add": "إضافة", - "signup_form_username": "إسم المستخدم", - "signup_form_confirm_password": "تأكيد كلمة المرور", + "common_save": "احفظ", + "common_add": "أضف", + "signup_form_username": "اسم المستخدم", + "signup_form_confirm_password": "أكِّد كلمة السر", "login_title": "تسجيل الدخول إلى حسابك ntfy", "login_form_button_submit": "الولوج", "login_link_signup": "إنشاء حساب", "login_disabled": "تم تعطيل تسجيل الدخول", "action_bar_account": "الحساب", - "action_bar_change_display_name": "تغيير الإسم المعروض", + "action_bar_change_display_name": "غيّر الإسم المعروض", "signup_error_creation_limit_reached": "تم بلوغ حد إنشاء الحسابات", "action_bar_reservation_add": "حجز الموضوع", "action_bar_reservation_edit": "تغيير الحجز", "action_bar_profile_title": "الملف التعريفي", "action_bar_profile_settings": "اﻹعدادات", - "action_bar_profile_logout": "الخروج", + "action_bar_profile_logout": "اخرج", "action_bar_sign_in": "الولوج", - "action_bar_sign_up": "إنشاء حساب", + "action_bar_sign_up": "أنشئ حساب", "nav_button_account": "الحساب", "nav_upgrade_banner_label": "قم بالترقية إلى NTFY Pro", "reserve_dialog_checkbox_label": "حجز الموضوع وإعداد الوصول", - "subscribe_dialog_subscribe_button_generate_topic_name": "توليد إسم", + "subscribe_dialog_subscribe_button_generate_topic_name": "ولِّد اسم", "subscribe_dialog_error_topic_already_reserved": "الموضوع محجوز بالفعل", "account_basics_title": "الحساب", "account_basics_username_title": "إسم المستخدم", "account_basics_username_description": "مرحبًا، هذا أنت ❤", "account_basics_username_admin_tooltip": "أنت مدير", - "account_basics_password_title": "كلمة المرور", - "account_basics_password_description": "غيّر كلمة مرور حسابك", - "account_basics_password_dialog_title": "تغيير كلمة المرور", - "account_basics_password_dialog_current_password_label": "كلمة المرور الحالية", - "account_basics_password_dialog_new_password_label": "كلمة المرور الجديدة", - "account_basics_password_dialog_confirm_password_label": "تأكيد كلمة المرور", - "account_basics_password_dialog_button_submit": "تغيير كلمة المرور", - "account_basics_password_dialog_current_password_incorrect": "الكلمة السرية خاطئة", + "account_basics_password_title": "كلمة السر", + "account_basics_password_description": "غيّر كلمة سر حسابك", + "account_basics_password_dialog_title": "غيّر كلمة السر", + "account_basics_password_dialog_current_password_label": "كلمة السر الحالية", + "account_basics_password_dialog_new_password_label": "كلمة السر جديدة", + "account_basics_password_dialog_confirm_password_label": "أكِّد كلمة السر", + "account_basics_password_dialog_button_submit": "غيّر كلمة السر", + "account_basics_password_dialog_current_password_incorrect": "كلمة السر غير صحيحة", "account_usage_title": "الإستخدام", "account_usage_of_limit": "من {{limit}}", "account_usage_unlimited": "غير محدود", @@ -212,13 +212,13 @@ "account_usage_attachment_storage_title": "تخزين المرفقات", "account_delete_title": "حذف الحساب", "account_delete_description": "احذف حسابك نهائيا", - "account_delete_dialog_label": "كلمة المرور", + "account_delete_dialog_label": "كلمة السر", "account_upgrade_dialog_title": "تغيير فئة الحساب", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} رسائل يومية", "account_upgrade_dialog_tier_features_emails_other": "{{emails}} من رسائل البريد الإلكتروني اليومية", - "account_upgrade_dialog_button_cancel": "إلغاء", + "account_upgrade_dialog_button_cancel": "ألغِ", "account_upgrade_dialog_button_pay_now": "ادفع الآن واشترك", - "account_upgrade_dialog_button_cancel_subscription": "إلغاء الاشتراك", + "account_upgrade_dialog_button_cancel_subscription": "ألغِ الاشتراك", "account_tokens_title": "رموز الوصول", "account_tokens_table_token_header": "الرمز المميز", "account_tokens_table_last_access_header": "آخر وصول", @@ -235,7 +235,7 @@ "account_tokens_dialog_label": "التسمية، على سبيل المثال إشعارات الرادار", "account_tokens_dialog_button_create": "إنشاء رمز مميز", "account_tokens_dialog_button_update": "تحديث الرمز المميز", - "account_tokens_dialog_button_cancel": "إلغاء", + "account_tokens_dialog_button_cancel": "ألغِ", "account_tokens_dialog_expires_label": "تنتهي صلاحية الرمز المميز للوصول في", "account_tokens_dialog_expires_unchanged": "اترك تاريخ انتهاء الصلاحية دون تغيير", "account_tokens_dialog_expires_x_hours": "تنتهي صلاحية الرمز المميز في {{hours}} ساعات", @@ -243,7 +243,7 @@ "account_tokens_delete_dialog_title": "حذف الرمز المميز للوصول", "account_tokens_delete_dialog_submit_button": "حذف الرمز المميز نهائيا", "prefs_users_table_cannot_delete_or_edit": "لا يمكن حذف أو تحرير المستخدم الذي قام بتسجيل الدخول", - "prefs_reservations_add_button": "إضافة موضوع محجوز", + "prefs_reservations_add_button": "أضف موضوع محجوز", "prefs_reservations_table": "جدول المواضيع المحجوزة", "prefs_reservations_table_topic_header": "الموضوع", "prefs_reservations_table_access_header": "الوصول", @@ -256,19 +256,19 @@ "prefs_reservations_dialog_access_label": "الوصول", "reservation_delete_dialog_action_delete_title": "حذف الرسائل والمرفقات المخزنة مؤقتا", "reservation_delete_dialog_submit_button": "حذف الحجز", - "signup_title": "إنشاء حساب ntfy", - "common_cancel": "إلغاء", - "signup_form_password": "كلمة المرور", + "signup_title": "أنشئ حساب ntfy", + "common_cancel": "ألغِ", + "signup_form_password": "كلمة السر", "signup_already_have_account": "هل لديك حساب؟ قم بتسجيل الدخول!", - "signup_form_button_submit": "إنشاء حساب", - "signup_disabled": "تم تعطيل التسجيل", + "signup_form_button_submit": "أنشئ حساب", + "signup_disabled": "عُطّل التسجيل", "display_name_dialog_placeholder": "الإسم المعروض", "display_name_dialog_title": "تغيير الإسم المعروض", "account_basics_tier_basic": "أساسي", "account_usage_emails_title": "رسائل البريد الإلكتروني المرسلة", "account_usage_reservations_none": "لا توجد مواضيع محجوزة لهذا الحساب", "account_usage_cannot_create_portal_session": "تعذر فتح بوابة الفوترة", - "account_delete_dialog_button_cancel": "إلغاء", + "account_delete_dialog_button_cancel": "ألغِ", "account_delete_dialog_button_submit": "حذف الحساب نهائيا", "account_upgrade_dialog_button_update_subscription": "تحديث الاشتراك", "account_tokens_table_copied_to_clipboard": "تم نسخ الرمز المميز للوصول", @@ -276,31 +276,31 @@ "prefs_reservations_table_everyone_read_only": "يمكنني النشر والاشتراك ، ويمكن للجميع الاشتراك", "prefs_reservations_table_click_to_subscribe": "انقر للاشتراك", "reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا", - "action_bar_reservation_delete": "إزالة الحجز", + "action_bar_reservation_delete": "أزل الحجز", "display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.", - "prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة المرور في التخزين المحلي للمتصفح.", + "prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة السر في التخزين المحلي للمتصفح.", "notifications_more_details": "لمزيد من المعلومات، الرجاء الاطّلاع على موقع الويب أو على الدليل.", "publish_dialog_details_examples_description": "للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى الدليل.", "subscribe_dialog_subscribe_description": "قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \"PUT/POST\".", "prefs_notifications_sound_description_some": "تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها", "notifications_none_for_topic_description": "لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع.", "priority_low": "منخفضة", - "signup_form_toggle_password_visibility": "تبديل رؤية كلمة المرور", + "signup_form_toggle_password_visibility": "تبديل رؤية كلمة السر", "account_usage_limits_reset_daily": "يعاد تحديد حدود الاستخدام يوميا في منتصف الليل (UTC)", "account_tokens_table_label_header": "المُلصَقة", "account_upgrade_dialog_button_redirect_signup": "تسجيل فوري", "account_upgrade_dialog_tier_current_label": "الحالي", "account_tokens_dialog_expires_x_days": "تنتهي صلاحية الرمز المميز في غضون {{days}} أيام", - "prefs_reservations_dialog_title_add": "حجز موضوع", + "prefs_reservations_dialog_title_add": "احجز موضوع", "prefs_reservations_description": "يمكنك حجز أسماء الموضوعات للاستخدام الشخصي هنا. يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات الوصول للمستخدمين الآخرين إلى الموضوع.", - "prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات المرور مع حسابك.", + "prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات السر مع حسابك.", "reservation_delete_dialog_action_delete_description": "سيتم حذف الرسائل والمرفقات المخزنة مؤقتا نهائيا. لا يمكن التراجع عن هذا الإجراء.", "notifications_actions_http_request_title": "إرسال طلب HTTP {{method}} إلى {{url}}", "notifications_none_for_any_description": "لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك.", "error_boundary_description": "من الواضح أن هذا لا ينبغي أن يحدث. آسف جدًا بشأن هذا.
    إن كان لديك دقيقة، يرجى الإبلاغ عن ذلك على GitHub ، أو إعلامنا عبر Discord أو Matrix .", "nav_button_muted": "الإشعارات المكتومة", "priority_min": "دنيا", - "signup_error_username_taken": "تم حجز اسم المستخدم {{username}} مِن قَبلُ", + "signup_error_username_taken": "تم حجز اسم المستخدم {{username}} بالفعل", "action_bar_reservation_limit_reached": "بلغت الحد الأقصى", "prefs_reservations_delete_button": "إعادة تعيين الوصول إلى الموضوع", "prefs_reservations_edit_button": "تعديل الوصول إلى موضوع", @@ -323,7 +323,7 @@ "account_upgrade_dialog_interval_yearly": "سنويا", "account_upgrade_dialog_tier_features_no_reservations": "لا توجد مواضيع محجوزة", "account_upgrade_dialog_interval_yearly_discount_save": "وفر {{discount}}٪", - "publish_dialog_click_reset": "إزالة الرابط التشعبي URL للنقر", + "publish_dialog_click_reset": "أزل الرابط URL للنقر", "prefs_notifications_min_priority_description_max": "إظهار الإشعارات إذا كانت الأولوية 5 (كحد أقصى)", "publish_dialog_attachment_limits_file_reached": "يتجاوز الحد الأقصى للملف {{fileSizeLimit}}", "publish_dialog_attachment_limits_quota_reached": "يتجاوز الحصة، {{remainingBytes}} متبقية", @@ -335,16 +335,16 @@ "prefs_appearance_theme_light": "الوضع النهاري", "publish_dialog_checkbox_markdown": "تنسيق على هيئة ماركداون", "alert_not_supported_context_description": "الإشعارات مسموحة فقط على بروتوكول HTTPS المأمن, هذه القيود خصائص الإشعارات", - "publish_dialog_call_reset": "حذف اتصال بالهاتف", + "publish_dialog_call_reset": "احذف اتصال بالهاتف", "publish_dialog_call_label": "اتصال هاتفي", "publish_dialog_chip_call_label": "اتصال هاتفي", - "publish_dialog_delay_placeholder": "تأخير التوصيل, مثال {{unixTimestamp}}, {{relativeTime}}, او \"{{naturalLanguage}}\" (اللغة الإنجليزية فقط)", + "publish_dialog_delay_placeholder": "تأخير التوصيل، مثال {{unixTimestamp}}، {{relativeTime}}، أو \"{{naturalLanguage}}\" (اللغة الإنجليزية فقط)", "publish_dialog_attachment_limits_file_and_quota_reached": "تجاوز حجم {{fileSizeLimit}} الملف, {{remainingBytes}} متبقي", "prefs_reservations_dialog_title_delete": "حذف حجز موضوع", "publish_dialog_call_item": "اتصل برقم الهاتف {{number}}", "publish_dialog_chip_call_no_verified_numbers_tooltip": "لا يوجد ارقام هواتف معرفة", "action_bar_mute_notifications": "كتم الإشعارات", - "action_bar_unmute_notifications": "إلغاء كتم الإشعارات", + "action_bar_unmute_notifications": "ألغِ كتم الإشعارات", "alert_notification_ios_install_required_description": "اضغط على زر المشاركة ثم إضافة إلى الصفحة الرئيسية لتستقبل الإشعارات على أجهزة أبل", "alert_notification_ios_install_required_title": "يجب تثبيت الصفحة", "alert_notification_permission_denied_description": "الرجاء اعادة منح الصلاحيات في المتصفح", @@ -359,6 +359,10 @@ "account_basics_phone_numbers_dialog_verify_button_call": "اتصل بي", "account_basics_phone_numbers_dialog_code_label": "رمز التحقّق", "account_upgrade_dialog_tier_price_per_month": "شهر", - "prefs_appearance_theme_title": "الحُلّة", - "subscribe_dialog_subscribe_use_another_background_info": "لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً" + "prefs_appearance_theme_title": "السمة", + "subscribe_dialog_subscribe_use_another_background_info": "لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً", + "prefs_appearance_theme_system": "النظام (الافتراضي)", + "prefs_notifications_min_priority_low_and_higher": "أولوية منخفضة وأعلى", + "prefs_notifications_min_priority_default_and_higher": "الأولوية الافتراضية وما فوقها", + "prefs_notifications_min_priority_high_and_higher": "أولوية عالية وأعلى" } From 2343ce46bd0600e8f8a0fe6f3cb5492935095c22 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 25 Jan 2026 09:16:36 -0500 Subject: [PATCH 132/548] Release notes --- docs/publish.md | 3 +++ docs/releases.md | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 9e5a7503..6e6f8854 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3503,6 +3503,9 @@ Here's an example with a custom message, tags and a priority: ## Updating + deleting notifications _Supported on:_ :material-android: :material-firefox: +!!! info + This feature is not fully released yet. The ntfy Android 1.22.x is being released right now. This may take a week or so. + You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. diff --git a/docs/releases.md b/docs/releases.md index 254c92af..d9aa2aa8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -7,12 +7,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| | ntfy server | v2.16.0 | Jan 19, 2026 | -| ntfy Android app | v1.22.1 | Jan 20, 2026 | +| ntfy Android app | v1.22.2 | Jan 25, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. -### ntfy Android app v1.22.1 +### ntfy Android app v1.22.2 Released January 20, 2026 This release adds support for [updating and deleting notifications](publish.md#updating--deleting-notifications) (requires server v2.16.0), From 2ae962d957fbad3a941fcfa25e97a59ad4d5a8d0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 25 Jan 2026 09:24:30 -0500 Subject: [PATCH 133/548] Bump --- Makefile | 1 + go.mod | 20 +-- go.sum | 44 +++---- web/package-lock.json | 276 +++++++++++++++++++++--------------------- 4 files changed, 171 insertions(+), 170 deletions(-) diff --git a/Makefile b/Makefile index ed16cabc..3b9cfdc5 100644 --- a/Makefile +++ b/Makefile @@ -245,6 +245,7 @@ cli-deps-gcc-windows: cli-deps-update: go get -u + go mod tidy go install honnef.co/go/tools/cmd/staticcheck@latest go install golang.org/x/lint/golint@latest go install github.com/goreleaser/goreleaser/v2@latest diff --git a/go.mod b/go.mod index 1f7f6b4c..701e924a 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( golang.org/x/sync v0.19.0 golang.org/x/term v0.39.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.260.0 + google.golang.org/api v0.262.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -30,7 +30,7 @@ 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.18.0 + firebase.google.com/go/v4 v4.19.0 github.com/SherClockHolmes/webpush-go v1.4.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 @@ -42,21 +42,21 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth v0.18.1 // 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.5.3 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect github.com/AlekSi/pointer v1.2.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.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 github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect @@ -95,9 +95,9 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/net v0.49.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect + google.golang.org/genproto v0.0.0-20260122232226-8e98ce8d340d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 77e16c2b..2e213508 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/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.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= -cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= 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= @@ -22,20 +22,20 @@ cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= -firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw= -firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= +firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= +firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= 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.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= 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= @@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94 h1:kkHPnzHm5Ln7WA0XYjrr2ITA0l9Vs6H++Ni//P+SZso= +github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -263,16 +263,16 @@ 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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= -google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= +google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY= +google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI= 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-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE= -google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= -google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss= -google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto v0.0.0-20260122232226-8e98ce8d340d h1:hUplc9kLwH374NIY3PreRUK3Unc0xLm/W7MDsm0gCNo= +google.golang.org/genproto v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:SpjiK7gGN2j/djoQMxLl3QOe/J/XxNzC5M+YLecVVWU= +google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d h1:tUKoKfdZnSjTf5LW7xpG4c6SZ3Ozisn5eumcoTuMEN4= +google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/web/package-lock.json b/web/package-lock.json index e0a8c3c7..45fe975b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -194,17 +194,17 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", - "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", + "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", "lodash.debounce": "^4.0.8", - "resolve": "^1.22.10" + "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -2798,9 +2798,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", "cpu": [ "arm" ], @@ -2812,9 +2812,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", "cpu": [ "arm64" ], @@ -2826,9 +2826,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", "cpu": [ "arm64" ], @@ -2840,9 +2840,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", "cpu": [ "x64" ], @@ -2854,9 +2854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", "cpu": [ "arm64" ], @@ -2868,9 +2868,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", "cpu": [ "x64" ], @@ -2882,9 +2882,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", "cpu": [ "arm" ], @@ -2896,9 +2896,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", "cpu": [ "arm" ], @@ -2910,9 +2910,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", "cpu": [ "arm64" ], @@ -2924,9 +2924,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", "cpu": [ "arm64" ], @@ -2938,9 +2938,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", "cpu": [ "loong64" ], @@ -2952,9 +2952,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", "cpu": [ "loong64" ], @@ -2966,9 +2966,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", "cpu": [ "ppc64" ], @@ -2980,9 +2980,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", "cpu": [ "ppc64" ], @@ -2994,9 +2994,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", "cpu": [ "riscv64" ], @@ -3008,9 +3008,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", "cpu": [ "riscv64" ], @@ -3022,9 +3022,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", "cpu": [ "s390x" ], @@ -3036,9 +3036,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", "cpu": [ "x64" ], @@ -3050,9 +3050,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", "cpu": [ "x64" ], @@ -3064,9 +3064,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", "cpu": [ "x64" ], @@ -3078,9 +3078,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", "cpu": [ "arm64" ], @@ -3092,9 +3092,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", "cpu": [ "arm64" ], @@ -3106,9 +3106,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", "cpu": [ "ia32" ], @@ -3120,9 +3120,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", "cpu": [ "x64" ], @@ -3134,9 +3134,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", "cpu": [ "x64" ], @@ -3248,9 +3248,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "version": "19.2.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", "peer": true, "dependencies": { @@ -3643,14 +3643,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", - "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", + "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.6", "semver": "^6.3.1" }, "peerDependencies": { @@ -3672,13 +3672,13 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", + "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5" + "@babel/helper-define-polyfill-provider": "^0.6.6" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3702,9 +3702,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", - "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", + "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3823,9 +3823,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "dev": true, "funding": [ { @@ -3967,13 +3967,13 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", - "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", + "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.28.0" + "browserslist": "^4.28.1" }, "funding": { "type": "opencollective", @@ -4267,9 +4267,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.278", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", + "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==", "dev": true, "license": "ISC" }, @@ -6477,9 +6477,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -7628,9 +7628,9 @@ } }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "dev": true, "license": "MIT", "dependencies": { @@ -7644,31 +7644,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" } }, From b26546b709feaa3324facafe367cc4b072ab4c2f Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sun, 25 Jan 2026 19:19:31 +0300 Subject: [PATCH 134/548] chore: fix typos in server.yml config file Signed-off-by: Alexander Ivanov --- server/server.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/server.yml b/server/server.yml index 639ed492..598c41bc 100644 --- a/server/server.yml +++ b/server/server.yml @@ -160,7 +160,7 @@ # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set, # messages will additionally be sent out as e-mail using an external SMTP server. # -# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported. +# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTTLS are supported. # Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst). # # - smtp-sender-addr is the hostname:port of the SMTP server @@ -198,8 +198,8 @@ # - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890 # - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db # - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com -# - web-push-startup-queries is an optional list of queries to run on startup` -# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d`) +# - web-push-startup-queries is an optional list of queries to run on startup +# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d) # - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d) # # web-push-public-key: @@ -280,7 +280,7 @@ # # - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh". # - upstream-access-token is the token used to authenticate with the upstream server. This is only required -# if you exceed the upstream rate limits, or the uptream server requires authentication. +# if you exceed the upstream rate limits, or the upstream server requires authentication. # # upstream-base-url: # upstream-access-token: From 1a172eb73b0a7a4b8873398ec1ad9976fb5aca66 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sun, 25 Jan 2026 20:15:51 +0100 Subject: [PATCH 135/548] Translated using Weblate (Russian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/ --- web/public/static/langs/ru.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/web/public/static/langs/ru.json b/web/public/static/langs/ru.json index 0c2eaae3..7fcf78a0 100644 --- a/web/public/static/langs/ru.json +++ b/web/public/static/langs/ru.json @@ -30,11 +30,11 @@ "publish_dialog_topic_label": "Название темы", "publish_dialog_topic_placeholder": "Название темы, например phil_alerts", "publish_dialog_title_label": "Заголовок", - "publish_dialog_title_placeholder": "Заголовок уведомления, например Disk space alert", + "publish_dialog_title_placeholder": "Заголовок уведомления, например, Предупреждение о занятости диска", "publish_dialog_message_label": "Сообщение", "publish_dialog_message_placeholder": "Введите сообщение здесь", "publish_dialog_tags_label": "Тэги", - "publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например: warning, srv1-backup", + "publish_dialog_tags_placeholder": "Ярлыки, разделенные запятыми, например: warning, srv1-backup", "publish_dialog_priority_label": "Приоритет", "publish_dialog_click_label": "Ссылка при открытии", "publish_dialog_click_placeholder": "URL-адрес, который откроется при нажатии на уведомление", @@ -242,8 +242,8 @@ "action_bar_reservation_delete": "Удалить резервирование", "action_bar_profile_title": "Профиль", "action_bar_profile_settings": "Настройки", - "action_bar_profile_logout": "Выход", - "action_bar_sign_in": "Вход", + "action_bar_profile_logout": "Выйти", + "action_bar_sign_in": "Войти", "action_bar_sign_up": "Регистрация", "action_bar_change_display_name": "Изменить псевдоним", "message_bar_publish": "Опубликовать сообщение", @@ -395,7 +395,7 @@ "prefs_notifications_web_push_title": "Фоновые уведомления", "prefs_notifications_web_push_enabled_description": "Уведомления приходят даже когда веб-приложение не запущено (через Web Push)", "prefs_notifications_web_push_disabled_description": "Уведомления приходят, когда веб-приложение запущено (через WebSocket)", - "prefs_appearance_theme_title": "Тема", + "prefs_appearance_theme_title": "Тема оформления", "prefs_notifications_web_push_enabled": "Включено для {{server}}", "prefs_notifications_web_push_disabled": "Выключено", "notifications_actions_failed_notification": "Неудачное действие", @@ -403,5 +403,7 @@ "subscribe_dialog_subscribe_use_another_background_info": "Уведомления с других серверов не будут получены, когда веб-приложение не открыто", "prefs_appearance_theme_system": "Как в системе (по умолчанию)", "prefs_appearance_theme_dark": "Тёмная", - "prefs_appearance_theme_light": "Светлая" + "prefs_appearance_theme_light": "Светлая", + "account_basics_cannot_edit_or_delete_provisioned_user": "Пользователя, созданного автоматически, нельзя изменить или удалить", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить" } From f79c84e99e1ad0fa651831420b5c8e84d2b2a311 Mon Sep 17 00:00:00 2001 From: Kavyansh Khaitan <73186427+KavyanshKhaitan2@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:31:15 +0530 Subject: [PATCH 136/548] Fix syntax error in publish.md --- docs/publish.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/publish.md b/docs/publish.md index 6e6f8854..864e605b 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2154,7 +2154,7 @@ And the same example using [JSON publishing](#publish-as-json): method: 'POST', body: JSON.stringify({ topic: "myhome", - message": "Garage door has been open for 15 minutes. Close it?", + "message": "Garage door has been open for 15 minutes. Close it?", actions: [ { "action": "http", From 448c5bfb88930449f841abbd97e9acf68f9ffbac Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 26 Jan 2026 20:31:11 -0500 Subject: [PATCH 137/548] Terms of service --- docs/terms.md | 257 ++++++++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 258 insertions(+) create mode 100644 docs/terms.md diff --git a/docs/terms.md b/docs/terms.md new file mode 100644 index 00000000..26ced427 --- /dev/null +++ b/docs/terms.md @@ -0,0 +1,257 @@ +# Terms of Service + +**Last updated:** January 26, 2026 + +Please read these Terms of Service ("Terms") carefully before using the ntfy.sh website and service (the "Service") +operated by ntfy LLC ("us", "we", or "our"). + +Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These +Terms apply to all visitors, users, and others who access or use the Service. + +**By accessing or using the Service, you agree to be bound by these Terms. If you disagree with any part of the +Terms, you may not access the Service.** + +## Service description + +ntfy (pronounced "notify") is a simple HTTP-based pub-sub notification service. It allows you to send push +notifications to your phone or desktop via scripts from any computer, using a REST API. The Service includes: + +- The ntfy.sh hosted server +- The ntfy web application +- The ntfy mobile applications (Android and iOS) +- The ntfy command-line interface (CLI) + +The server software and mobile applications are open source and can be [self-hosted](install.md). These Terms +apply specifically to the ntfy.sh hosted service. + +## Subscriptions and billing + +### Free tier + +You may use the Service without creating an account or subscribing to a paid plan. Free usage is subject to +rate limits and other restrictions as described in our documentation. + +### Paid plans + +Some features of the Service are available only through paid subscription plans ("Subscriptions"). You will +be billed in advance on a recurring basis ("Billing Cycle"). Billing cycles are available on a monthly or +annual basis. + +At the end of each Billing Cycle, your Subscription will automatically renew under the same conditions unless +you cancel it or we cancel it. You may cancel your Subscription renewal through your account settings in the +web application. + +A valid payment method is required to process payment for your Subscription. You shall provide us with accurate +and complete billing information. By submitting such payment information, you authorize us to charge all +Subscription fees incurred through your account to your payment method. + +Payment processing is handled by Stripe. Your payment information is subject to Stripe's +[privacy policy](https://stripe.com/privacy) and [terms of service](https://stripe.com/legal). + +Should automatic billing fail to occur for any reason, we will retry the payment according to Stripe's retry +schedule. If payment continues to fail after multiple attempts, your Subscription will be canceled and your +account will revert to the free tier. + +### Fee changes + +We may, in our sole discretion and at any time, modify the Subscription fees for paid plans. Any fee change +will become effective at the end of the then-current Billing Cycle. + +We will provide you with reasonable prior notice of any change in Subscription fees to give you an opportunity +to cancel your Subscription before such change becomes effective. + +Your continued use of the Service after a fee change comes into effect constitutes your agreement to pay the +modified Subscription fee. + +## Refunds + +Refund requests for Subscriptions may be considered on a case-by-case basis and granted at the sole discretion +of ntfy LLC. To request a refund, please contact us at [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh). + +## User accounts + +When you create an account with us, you must provide information that is accurate, complete, and current at +all times. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of +your account. + +You are responsible for: + +- Safeguarding the password that you use to access the Service +- Any activities or actions under your account, whether your password is with our Service or a third-party service +- Keeping your account credentials confidential + +You agree not to disclose your password to any third party. You must notify us immediately upon becoming aware +of any breach of security or unauthorized use of your account. + +You represent that you are at least 18 years old, or that you are at least the minimum age required to form +a binding contract in your jurisdiction, and have the legal authority to enter into these Terms. + +## Acceptable use + +You agree not to use the Service to: + +- Send spam, unsolicited messages, or messages to recipients who have not consented to receive them +- Distribute malware, viruses, or any other malicious software +- Transmit illegal content or content that violates the rights of others +- Harass, abuse, or harm another person or group +- Impersonate any person or entity, or falsely state or misrepresent your affiliation with a person or entity +- Interfere with or disrupt the Service or servers or networks connected to the Service +- Attempt to gain unauthorized access to the Service, other accounts, or computer systems +- Use the Service for any illegal purpose or in violation of any applicable laws or regulations +- Circumvent rate limits or other technical restrictions +- Use the Service in a manner that could reasonably be expected to impose an unreasonable or disproportionately + large load on our infrastructure + +We reserve the right to investigate and take appropriate action against anyone who, in our sole discretion, +violates this provision, including removing content, terminating accounts, and reporting to law enforcement. + +### Topic names + +Topic names on ntfy.sh are public. If you use the Service without access controls, your topic name functions +as a password. You are responsible for choosing topic names that cannot be easily guessed. We are not responsible +for any unauthorized access to messages published to easily guessable topic names. + +For reserved topics and access control features, consider subscribing to a paid plan. + +## Intellectual property + +### Open source software + +The ntfy server, web application, and mobile applications are open source software, dual-licensed under the +[Apache License 2.0](https://github.com/binwiederhier/ntfy/blob/main/LICENSE) and +[GPLv2](https://github.com/binwiederhier/ntfy/blob/main/LICENSE.GPLv2). You are free to use, modify, and +distribute the software in accordance with these licenses. + +### Trademarks + +The ntfy name, logo, and branding are trademarks of ntfy LLC. Our trademarks may not be used in connection +with any product or service without our prior written consent. + +### Your content + +You retain ownership of any content you transmit through the Service. By using the Service, you grant us a +limited license to process and transmit your content solely for the purpose of providing the Service. + +## Service availability + +The Service is provided on a "best effort" basis. We do not guarantee any specific uptime or availability. + +We strive to maintain high availability, but the Service may be interrupted for maintenance, updates, or +due to circumstances beyond our control. We will make reasonable efforts to notify users of planned +maintenance when possible. + +For applications requiring guaranteed uptime or specific service level agreements, we recommend +[self-hosting your own ntfy server](install.md). + +A [status page](https://ntfy.statuspage.io/) is available to check the current operational status of the Service. + +## Third-party services + +The Service relies on third-party services to provide certain functionality: + +- **Firebase Cloud Messaging (FCM)** - For push notifications to Android and iOS devices +- **Twilio** - For phone call notifications +- **Amazon SES** - For email notifications +- **Stripe** - For payment processing + +Your use of these features is subject to the respective third-party terms and privacy policies. For more +details, see our [privacy policy](privacy.md). + +## Links to other websites + +Our Service may contain links to third-party websites or services that are not owned or controlled by us. + +We have no control over, and assume no responsibility for, the content, privacy policies, or practices of +any third-party websites or services. You acknowledge and agree that we shall not be responsible or liable, +directly or indirectly, for any damage or loss caused by or in connection with the use of any such content, +goods, or services available through any such websites or services. + +## Termination + +We may terminate or suspend your account immediately, without prior notice or liability, for any reason +whatsoever, including without limitation if you breach these Terms. + +Upon termination, your right to use the Service will immediately cease. If you wish to terminate your account, +you may do so through your account settings or by simply discontinuing use of the Service. + +Termination of your account will result in the deletion of your account data in accordance with our +[privacy policy](privacy.md). + +We may retain certain data as required to comply with legal obligations, resolve disputes, and enforce our +agreements, as described in our privacy policy. + +## Limitation of liability + +In no event shall ntfy LLC, nor its owner, employees, partners, agents, suppliers, or affiliates, be liable +for any indirect, incidental, special, consequential, or punitive damages, including without limitation: + +- Loss of profits, data, use, goodwill, or other intangible losses +- Damages resulting from your access to, use of, or inability to access or use the Service +- Damages resulting from any conduct or content of any third party on the Service +- Damages resulting from any content obtained from the Service +- Damages resulting from unauthorized access, use, or alteration of your transmissions or content + +This limitation applies whether based on warranty, contract, tort (including negligence), or any other legal +theory, whether or not we have been informed of the possibility of such damage, and even if a remedy set +forth herein is found to have failed of its essential purpose. + +## Indemnification + +You agree to defend, indemnify, and hold harmless ntfy LLC and its owner, employees, partners, agents, suppliers, +and affiliates from and against any claims, damages, obligations, losses, liabilities, costs, or debt, and +expenses (including but not limited to attorney's fees) arising from: + +- Your use of and access to the Service +- Your violation of any term of these Terms +- Your violation of any applicable law or regulation +- Your content, including any claim that your content infringes or misappropriates the rights of any third party + +## Disclaimer + +Your use of the Service is at your sole risk. The Service is provided on an "AS IS" and "AS AVAILABLE" basis, +without warranties of any kind, whether express or implied, including but not limited to implied warranties of +merchantability, fitness for a particular purpose, non-infringement, or course of performance. + +ntfy LLC does not warrant that: + +- The Service will function uninterrupted, secure, or available at any particular time or location +- Any errors or defects will be corrected +- The Service is free of viruses or other harmful components +- The results of using the Service will meet your requirements +- Messages will be delivered successfully or in a timely manner + +If your use case requires guaranteed message delivery, high availability, or handling of sensitive data, we +strongly recommend [self-hosting your own ntfy server](install.md) where you have full control over the +infrastructure and data. + +## Governing law + +These Terms shall be governed and construed in accordance with the laws of the State of Connecticut, United States, +without regard to its conflict of law provisions. + +Any legal action or proceeding arising under these Terms shall be brought exclusively in the federal or state +courts located in Connecticut, and the parties hereby consent to personal jurisdiction and venue therein. + +Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. +If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions +of these Terms will remain in effect. + +These Terms constitute the entire agreement between us regarding our Service and supersede any prior agreements +we might have had regarding the Service. + +## Changes to these Terms + +We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is +material, we will try to provide at least 30 days' notice prior to any new terms taking effect. + +What constitutes a material change will be determined at our sole discretion. Changes will be posted on this +page with an updated "Last updated" date. You may also review all changes in the +[Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/terms.md). + +By continuing to access or use our Service after those revisions become effective, you agree to be bound by +the revised Terms. If you do not agree to the new Terms, please stop using the Service. + +## Contact + +If you have any questions about these Terms, please see our [contact page](contact.md) or email us at +[support@mail.ntfy.sh](mailto:support@mail.ntfy.sh). diff --git a/mkdocs.yml b/mkdocs.yml index 1e15b5db..f2a7afb7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -101,6 +101,7 @@ nav: - "Development": develop.md - "Contributing": contributing.md - "Privacy policy": privacy.md + - "Terms of Service": terms.md - "Contact": contact.md From cea5fececbb1759b2ca6bcca56e84b41d3c36264 Mon Sep 17 00:00:00 2001 From: Kavyansh Khaitan <73186427+KavyanshKhaitan2@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:00:29 +0530 Subject: [PATCH 138/548] Fix JSON syntax for JS examples (and make one of them more consistent) --- docs/publish.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 864e605b..12341f01 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1619,7 +1619,7 @@ And the same example using [JSON publishing](#publish-as-json): method: 'POST', body: JSON.stringify({ topic: "myhome", - message": "Somebody retweeted your tweet.", + message: "Somebody retweeted your tweet.", actions: [ { action: "view", @@ -1879,7 +1879,7 @@ And the same example using [JSON publishing](#publish-as-json): method: 'POST', body: JSON.stringify({ topic: "wifey", - message": "Your wife requested you send a picture of yourself.", + message: "Your wife requested you send a picture of yourself.", actions: [ { "action": "broadcast", @@ -2154,7 +2154,7 @@ And the same example using [JSON publishing](#publish-as-json): method: 'POST', body: JSON.stringify({ topic: "myhome", - "message": "Garage door has been open for 15 minutes. Close it?", + message: "Garage door has been open for 15 minutes. Close it?", actions: [ { "action": "http", From df228359321ec760102dc2f6dc09f15485975368 Mon Sep 17 00:00:00 2001 From: 109247019824 <109247019824@users.noreply.hosted.weblate.org> Date: Tue, 27 Jan 2026 21:35:04 +0100 Subject: [PATCH 139/548] Translated using Weblate (Bulgarian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/ --- web/public/static/langs/bg.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json index 465e969b..7c8435da 100644 --- a/web/public/static/langs/bg.json +++ b/web/public/static/langs/bg.json @@ -75,7 +75,7 @@ "publish_dialog_attachment_limits_quota_reached": "надвишава квотата, остават {{remainingBytes}}", "publish_dialog_priority_high": "Висок приоритет", "publish_dialog_priority_default": "Подразбиран приоритет", - "publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за диска", + "publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за дисково пространство", "publish_dialog_tags_label": "Етикети", "publish_dialog_email_label": "Адрес на електронна поща", "publish_dialog_priority_max": "Най-висок приоритет", From f15a74521a19a2dd6de1698bbba5020de2c14dfb Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Fri, 30 Jan 2026 22:40:19 +0100 Subject: [PATCH 140/548] Update add Uptime Monitor to the Official integrations list --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index 1ed61faf..bbe1dd76 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -43,6 +43,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader - [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform - [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform +- [Uptime Monitor](https://github.com/Rabbit-Company/UptimeMonitor-Server) - Self-hosted, enterprise-grade uptime monitoring and alerting system ## Integration via HTTP/SMTP/etc. From 27a4a71b2340f8cb25c068bc938e065f257fe1b7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 12:01:57 -0500 Subject: [PATCH 141/548] Update docs --- docs/publish.md | 3 --- docs/releases.md | 2 +- docs/subscribe/phone.md | 10 +++++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 12341f01..3c8cbdcc 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3503,9 +3503,6 @@ Here's an example with a custom message, tags and a priority: ## Updating + deleting notifications _Supported on:_ :material-android: :material-firefox: -!!! info - This feature is not fully released yet. The ntfy Android 1.22.x is being released right now. This may take a week or so. - You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant. diff --git a/docs/releases.md b/docs/releases.md index d9aa2aa8..c22ec719 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,7 +15,7 @@ Please check out the release notes for [upcoming releases](#not-released-yet) be ### ntfy Android app v1.22.2 Released January 20, 2026 -This release adds support for [updating and deleting notifications](publish.md#updating--deleting-notifications) (requires server v2.16.0), +This release adds support for [updating and deleting notifications](publish.md#updating-deleting-notifications) (requires server v2.16.0), as well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates), and a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting). diff --git a/docs/subscribe/phone.md b/docs/subscribe/phone.md index a022b2a7..b8bc5536 100644 --- a/docs/subscribe/phone.md +++ b/docs/subscribe/phone.md @@ -82,9 +82,8 @@ you'll see as a permanent notification that looks like this:
    Instant delivery foreground notification
    -Android does not allow you to dismiss this notification, unless you turn off the notification channel in the settings. -To do so, long-press on the foreground notification (screenshot above) and navigate to the settings. Then toggle the -"Subscription Service" off: +To turn off this notification, long-press on the foreground notification (screenshot above) and navigate to the +settings. Then toggle the "Subscription Service" off:
    ![foreground service](../static/img/notification-settings.png){ width=500 } @@ -102,6 +101,11 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app. It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor. +!!! info "F-Droid: Always instant delivery" + Since the F-Droid build does not include Firebase, **all subscriptions use instant delivery by default**, and + there is no option to disable it. The F-Droid app hides all mentions of "instant delivery" in the UI, since + showing options that can't be changed would only be confusing. + ## Publishing messages _Supported on:_ :material-android: From c33f1494f5f4942abb9a3de061361d80f3201fad Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 12:20:19 -0500 Subject: [PATCH 142/548] Release notes --- docs/releases.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index c22ec719..47a1fd3c 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,7 +12,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release Please check out the release notes for [upcoming releases](#not-released-yet) below. -### ntfy Android app v1.22.2 +## ntfy Android app v1.22.2 Released January 20, 2026 This release adds support for [updating and deleting notifications](publish.md#updating-deleting-notifications) (requires server v2.16.0), @@ -1665,4 +1665,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -_Nothing here_ \ No newline at end of file +### ntfy server v2.17.x (UNRELEASED) + +**Bug fixes + maintenance:** + +* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) From 4546eb02a1593d1a2f34efb5f855c78d287ee55f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 12:22:48 -0500 Subject: [PATCH 143/548] Docs fix: Update Kustomize config file, closes #1367 --- docs/install.md | 24 ++++++++++++------------ docs/releases.md | 1 + 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/install.md b/docs/install.md index 02d1672b..18423ff7 100644 --- a/docs/install.md +++ b/docs/install.md @@ -567,18 +567,18 @@ kubectl apply -k /ntfy cpu: 150m memory: 150Mi volumeMounts: - - mountPath: /etc/ntfy - subPath: server.yml - name: config-volume # generated vie configMapGenerator from kustomization file - - mountPath: /var/cache/ntfy - name: cache-volume #cache volume mounted to persistent volume - volumes: - - name: config-volume - configMap: # uses configmap generator to parse server.yml to configmap - name: server-config - - name: cache-volume - persistentVolumeClaim: # stores /cache/ntfy in defined pv - claimName: ntfy-pvc + - mountPath: /etc/ntfy/server.yml + subPath: server.yml + name: config-volume # generated via configMapGenerator from kustomization file + - mountPath: /var/cache/ntfy + name: cache-volume # cache volume mounted to persistent volume + volumes: + - name: config-volume + configMap: # uses configmap generator to parse server.yml to configmap + name: server-config + - name: cache-volume + persistentVolumeClaim: # stores /cache/ntfy in defined pv + claimName: ntfy-pvc ``` === "ntfy-pvc.yaml" diff --git a/docs/releases.md b/docs/releases.md index 47a1fd3c..71e64fea 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1670,3 +1670,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** * Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) +* Docs: Fix Kustomize config - correct volumeMount path and volumes indentation ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) From 63e9b8425f4e37d68fd5785d952b9e491fab5943 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 12:33:19 -0500 Subject: [PATCH 144/548] Add validation to "add user" dialog, closes #1566 --- docs/releases.md | 1 + web/public/static/langs/en.json | 2 ++ web/src/components/Preferences.jsx | 15 ++++++++++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 71e64fea..77588cf1 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1669,5 +1669,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** +* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts)) * Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) * Docs: Fix Kustomize config - correct volumeMount path and volumes indentation ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 15e78976..19fe2195 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -357,6 +357,8 @@ "prefs_users_dialog_title_add": "Add user", "prefs_users_dialog_title_edit": "Edit user", "prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh", + "prefs_users_dialog_base_url_invalid": "Invalid URL format. Must start with http:// or https://", + "prefs_users_dialog_base_url_exists": "A user for this service URL already exists", "prefs_users_dialog_username_label": "Username, e.g. phil", "prefs_users_dialog_password_label": "Password", "prefs_appearance_title": "Appearance", diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index 8621a263..bd97da3a 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -429,13 +429,14 @@ const UserDialog = (props) => { const [password, setPassword] = useState(""); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const editMode = props.user !== null; + const baseUrlValid = baseUrl.length === 0 || validUrl(baseUrl); + const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl); + const baseUrlError = baseUrl.length > 0 && (!baseUrlValid || baseUrlExists); const addButtonEnabled = (() => { if (editMode) { return username.length > 0 && password.length > 0; } - const baseUrlValid = validUrl(baseUrl); - const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl); - return baseUrlValid && !baseUrlExists && username.length > 0 && password.length > 0; + return validUrl(baseUrl) && !baseUrlExists && username.length > 0 && password.length > 0; })(); const handleSubmit = async () => { props.onSubmit({ @@ -467,6 +468,14 @@ const UserDialog = (props) => { type="url" fullWidth variant="standard" + error={baseUrlError} + helperText={ + baseUrl.length > 0 && !baseUrlValid + ? t("prefs_users_dialog_base_url_invalid") + : baseUrlExists + ? t("prefs_users_dialog_base_url_exists") + : "" + } /> )} Date: Sun, 1 Feb 2026 12:41:00 -0500 Subject: [PATCH 145/548] Fix markdown message line height, closes #1139 --- docs/releases.md | 1 + web/src/components/Notifications.jsx | 2 +- web/src/components/Preferences.jsx | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 77588cf1..70b4171c 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1669,6 +1669,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** +* Web: Fix markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz)) * Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts)) * Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) * Docs: Fix Kustomize config - correct volumeMount path and volumes indentation ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 449b238b..fba4ba61 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -188,7 +188,7 @@ const MarkdownContainer = styled("div")` } p { - line-height: 1.2; + line-height: 1.5; } blockquote, diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index bd97da3a..5f46a663 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -473,8 +473,8 @@ const UserDialog = (props) => { baseUrl.length > 0 && !baseUrlValid ? t("prefs_users_dialog_base_url_invalid") : baseUrlExists - ? t("prefs_users_dialog_base_url_exists") - : "" + ? t("prefs_users_dialog_base_url_exists") + : "" } /> )} From b57bc5d86e92fe8f5ae5b1fe4f4b13d0c41263a9 Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Sun, 1 Feb 2026 20:34:58 +0100 Subject: [PATCH 146/548] Update move project from Official integrations to Projects + Scripts. --- docs/integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations.md b/docs/integrations.md index bbe1dd76..c836d000 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -43,7 +43,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader - [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform - [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform -- [Uptime Monitor](https://github.com/Rabbit-Company/UptimeMonitor-Server) - Self-hosted, enterprise-grade uptime monitoring and alerting system ## Integration via HTTP/SMTP/etc. @@ -183,6 +182,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy. - [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go) - [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go) +- [Uptime Monitor](https://github.com/Rabbit-Company/UptimeMonitor-Server) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS) ## Blog + forum posts From 946a2b6fbe3b512de84b43e544f25a7aa429f040 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 16:56:02 -0500 Subject: [PATCH 147/548] Use full URL in curl example on empty topic pages, closes #1535, closes #1435 --- docs/releases.md | 5 +++-- web/src/components/Notifications.jsx | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 70b4171c..99544d38 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1669,7 +1669,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** -* Web: Fix markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz)) +* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz)) +* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR) * Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts)) * Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) -* Docs: Fix Kustomize config - correct volumeMount path and volumes indentation ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) +* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index fba4ba61..d3699102 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -33,7 +33,7 @@ import { maybeActionErrors, openUrl, shortUrl, - topicShortUrl, + topicUrl, unmatchedTags, } from "../app/utils"; import { formatMessage, formatTitle, isImage } from "../app/notificationUtils"; @@ -588,7 +588,7 @@ const UserAction = (props) => { const NoNotifications = (props) => { const { t } = useTranslation(); - const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); + const topicUrlResolved = topicUrl(props.subscription.baseUrl, props.subscription.topic); return ( @@ -601,7 +601,7 @@ const NoNotifications = (props) => { {t("notifications_example")}:
    {'$ curl -d "Hi" '} - {topicShortUrlResolved} + {topicUrlResolved} @@ -614,7 +614,7 @@ const NoNotifications = (props) => { const NoNotificationsWithoutSubscription = (props) => { const { t } = useTranslation(); const subscription = props.subscriptions[0]; - const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic); + const topicUrlResolved = topicUrl(subscription.baseUrl, subscription.topic); return ( @@ -627,7 +627,7 @@ const NoNotificationsWithoutSubscription = (props) => { {t("notifications_example")}:
    {'$ curl -d "Hi" '} - {topicShortUrlResolved} + {topicUrlResolved}
    From 9fbf5e460eb13094d5d19545b25857323020abbf Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 17:35:09 -0500 Subject: [PATCH 148/548] Release notes --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index 99544d38..cc016a74 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1669,6 +1669,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** +* Refactor: Use `slices.Contains` from Go 1.21 stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan)) * Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz)) * Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR) * Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts)) From 7207839b2a5a6f7b2f012ed774781add4efcd1ff Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 17:42:56 -0500 Subject: [PATCH 149/548] Fix long lines in web app by adding horizontal scroll, closes #1363 --- docs/releases.md | 3 ++- web/src/components/Notifications.jsx | 2 +- web/src/components/Preferences.jsx | 16 +++++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index cc016a74..5f030709 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1669,9 +1669,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** -* Refactor: Use `slices.Contains` from Go 1.21 stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan)) * Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz)) +* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL)) * Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR) * Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts)) +* Refactor: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan)) * Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) * Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index d3699102..b7be8de2 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -303,7 +303,7 @@ const NotificationItem = (props) => { {formatTitle(notification)}
    )} - + {maybeActionErrors(notification)} diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index 5f46a663..e83d668f 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -438,6 +438,14 @@ const UserDialog = (props) => { } return validUrl(baseUrl) && !baseUrlExists && username.length > 0 && password.length > 0; })(); + const baseUrlHelperText = (() => { + if (baseUrl.length > 0 && !baseUrlValid) { + return t("prefs_users_dialog_base_url_invalid"); + } else if (baseUrlExists) { + return t("prefs_users_dialog_base_url_exists"); + } + return ""; + })(); const handleSubmit = async () => { props.onSubmit({ baseUrl, @@ -469,13 +477,7 @@ const UserDialog = (props) => { fullWidth variant="standard" error={baseUrlError} - helperText={ - baseUrl.length > 0 && !baseUrlValid - ? t("prefs_users_dialog_base_url_invalid") - : baseUrlExists - ? t("prefs_users_dialog_base_url_exists") - : "" - } + helperText={baseUrlHelperText} /> )} Date: Sun, 1 Feb 2026 20:52:06 -0500 Subject: [PATCH 150/548] Release notes, and send to ntfy extension --- docs/integrations.md | 1 + docs/releases.md | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/docs/integrations.md b/docs/integrations.md index c836d000..629f7cd2 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -183,6 +183,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go) - [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go) - [Uptime Monitor](https://github.com/Rabbit-Company/UptimeMonitor-Server) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS) +- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS) ## Blog + forum posts diff --git a/docs/releases.md b/docs/releases.md index 5f030709..9e99d1e8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1665,6 +1665,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy Android v2.23.x (UNRELEASED) + +**Features:** + +* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring) +* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting) + ### ntfy server v2.17.x (UNRELEASED) **Bug fixes + maintenance:** From b2f404657444cd050db8b8e48c5398fe2bfcef61 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 22:12:33 -0500 Subject: [PATCH 151/548] Release notes --- docs/releases.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 9e99d1e8..8ef98026 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1665,12 +1665,17 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy Android v2.23.x (UNRELEASED) +### ntfy Android v1.23.x (UNRELEASED) **Features:** * Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring) * Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting) +* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582)) + +**Bug fixes + maintenance:** + +* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1)) ### ntfy server v2.17.x (UNRELEASED) From f382a13109dbebf32e1fe2d5440dfa9c77388b55 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 22:17:35 -0500 Subject: [PATCH 152/548] Lint --- web/src/components/Preferences.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index e83d668f..3ef62189 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -441,7 +441,8 @@ const UserDialog = (props) => { const baseUrlHelperText = (() => { if (baseUrl.length > 0 && !baseUrlValid) { return t("prefs_users_dialog_base_url_invalid"); - } else if (baseUrlExists) { + } + if (baseUrlExists) { return t("prefs_users_dialog_base_url_exists"); } return ""; From e01e9d649106c4ec02943a4dfba8d2f7427d4856 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Feb 2026 22:37:51 -0500 Subject: [PATCH 153/548] Changelog --- docs/releases.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 8ef98026..76da11f7 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1681,8 +1681,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** -* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz)) -* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL)) +* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting) +* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting) +* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting) * Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR) * Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts)) * Refactor: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan)) From 23aff6fb062fdf4c452556e56af8dbd324f7a75b Mon Sep 17 00:00:00 2001 From: Ziga Zajc Date: Mon, 2 Feb 2026 05:48:02 +0100 Subject: [PATCH 154/548] Update change URL of Uptime Monitor to official website --- docs/integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations.md b/docs/integrations.md index 629f7cd2..e8a7d15f 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -182,7 +182,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy. - [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go) - [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go) -- [Uptime Monitor](https://github.com/Rabbit-Company/UptimeMonitor-Server) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS) +- [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS) - [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS) ## Blog + forum posts From ad334178dee100baaea5f52cf26c48dab131cc8f Mon Sep 17 00:00:00 2001 From: Kachelkaiser Date: Mon, 2 Feb 2026 10:38:03 +0100 Subject: [PATCH 155/548] Translated using Weblate (German) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/ --- web/public/static/langs/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index dc98901e..360ead82 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -73,7 +73,7 @@ "publish_dialog_tags_placeholder": "Komma-getrennte Liste von Tags, z.B. Warnung, srv1-Backup", "publish_dialog_priority_label": "Priorität", "publish_dialog_filename_label": "Dateiname", - "publish_dialog_title_placeholder": "Benachrichtigungs-Titel, z.B. CPU-Last-Warnung", + "publish_dialog_title_placeholder": "Benachrichtigungstitel, z. B. Speicherplatzwarnung", "publish_dialog_tags_label": "Tags", "publish_dialog_click_label": "Klick-URL", "publish_dialog_click_placeholder": "URL die geöffnet werden soll, wenn die Benachrichtigung angeklickt wird", From bfb47c4046bfb501193b7b6515916de3423619c3 Mon Sep 17 00:00:00 2001 From: Yaron Shahrabani Date: Tue, 3 Feb 2026 07:55:04 +0100 Subject: [PATCH 156/548] Added translation using Weblate (Hebrew) --- web/public/static/langs/he.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/he.json diff --git a/web/public/static/langs/he.json b/web/public/static/langs/he.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/he.json @@ -0,0 +1 @@ +{} From fe386e31ddba51ec9fb6d3076b99cfedf56c0be5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 3 Feb 2026 18:36:53 -0500 Subject: [PATCH 157/548] Changelog for #1112 (Android notification timestamp fix) --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index 76da11f7..769c230f 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1676,6 +1676,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** * Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1)) +* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting) ### ntfy server v2.17.x (UNRELEASED) From 325983deafa84c8a3caf0703eb402218906726df Mon Sep 17 00:00:00 2001 From: Yaron Shahrabani Date: Tue, 3 Feb 2026 08:07:47 +0100 Subject: [PATCH 158/548] Translated using Weblate (Hebrew) Currently translated at 8.1% (33 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/he/ --- web/public/static/langs/he.json | 36 ++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/he.json b/web/public/static/langs/he.json index 0967ef42..b525092c 100644 --- a/web/public/static/langs/he.json +++ b/web/public/static/langs/he.json @@ -1 +1,35 @@ -{} +{ + "common_cancel": "ביטול", + "common_save": "שמירה", + "common_add": "הוספה", + "common_back": "חזרה", + "common_copy_to_clipboard": "העתקה ללוח הגזירים", + "signup_title": "יצירת חשבון ntfy", + "signup_form_username": "שם משתמש", + "signup_form_password": "סיסמה", + "signup_form_confirm_password": "אישור סיסמה", + "signup_form_button_submit": "הרשמה", + "signup_form_toggle_password_visibility": "הצגת/הסתרת סיסמה", + "signup_already_have_account": "כבר יש לך חשבון? אפשר להיכנס איתו!", + "signup_disabled": "הרשמה כבויה", + "signup_error_username_taken": "שם המשתמש {{username}} כבר תפוס", + "signup_error_creation_limit_reached": "הגעת למגבלת יצירת חשבונות", + "login_title": "כניסה לחשבון ה־ntfy שלך", + "login_form_button_submit": "כניסה", + "login_link_signup": "הרשמה", + "login_disabled": "הכניסה מושבתת", + "action_bar_show_menu": "הצגת תפריט", + "action_bar_logo_alt": "הלוגו של ntfy", + "action_bar_settings": "הגדרות", + "action_bar_account": "חשבון", + "action_bar_change_display_name": "החלפת שם תצוגה", + "action_bar_reservation_add": "שימור נושא", + "action_bar_reservation_edit": "החלפת מצב שימור", + "action_bar_reservation_delete": "הסרת שימור", + "action_bar_reservation_limit_reached": "הגעת למגבלה", + "action_bar_send_test_notification": "שליחת התראת ניסוי", + "action_bar_clear_notifications": "לפנות את כל ההתראות", + "action_bar_mute_notifications": "השתקת התראות", + "action_bar_unmute_notifications": "ביטול השתקת התראות", + "action_bar_unsubscribe": "ביטול מינוי" +} From 08eaafa77b8284d0651e424667342abf2cb13d45 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Feb 2026 06:05:54 -0800 Subject: [PATCH 159/548] Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors --- docs/releases.md | 2 + go.mod | 38 ++-- go.sum | 68 +++---- server/errors.go | 15 ++ server/log.go | 11 +- server/server.go | 15 +- server/server_test.go | 59 ++++++ web/package-lock.json | 417 +++++++++++++++++++++--------------------- 8 files changed, 356 insertions(+), 269 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 769c230f..aab2500d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1677,11 +1677,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1)) * Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting) +* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting) ### ntfy server v2.17.x (UNRELEASED) **Bug fixes + maintenance:** +* Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting) * Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting) * Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting) * Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting) diff --git a/go.mod b/go.mod index 701e924a..992aced8 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,14 @@ module heckel.io/ntfy/v2 -go 1.24.0 - -toolchain go1.24.5 +go 1.24.6 require ( cloud.google.com/go/firestore v1.21.0 // indirect - cloud.google.com/go/storage v1.59.1 // indirect + cloud.google.com/go/storage v1.59.2 // 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.18.0 - github.com/gabriel-vasile/mimetype v1.4.12 + github.com/gabriel-vasile/mimetype v1.4.13 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-sqlite3 v1.14.33 github.com/olebedev/when v1.1.0 @@ -21,7 +19,7 @@ require ( golang.org/x/sync v0.19.0 golang.org/x/term v0.39.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.262.0 + google.golang.org/api v0.265.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -56,7 +54,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect @@ -66,12 +64,12 @@ require ( github.com/go-logr/logr v1.4.3 // 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.0 // 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.11 // indirect - github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect @@ -84,20 +82,20 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/net v0.49.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260122232226-8e98ce8d340d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect + google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2e213508..c42210b4 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7 cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58= -cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw= +cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= @@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94 h1:kkHPnzHm5Ln7WA0XYjrr2ITA0l9Vs6H++Ni//P+SZso= -github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -68,8 +68,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +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/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -81,8 +81,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -98,8 +98,8 @@ 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.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= -github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= -github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= 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= @@ -156,24 +156,24 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= +go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -263,16 +263,16 @@ 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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY= -google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI= +google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU= +google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY= 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-20260122232226-8e98ce8d340d h1:hUplc9kLwH374NIY3PreRUK3Unc0xLm/W7MDsm0gCNo= -google.golang.org/genproto v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:SpjiK7gGN2j/djoQMxLl3QOe/J/XxNzC5M+YLecVVWU= -google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d h1:tUKoKfdZnSjTf5LW7xpG4c6SZ3Ozisn5eumcoTuMEN4= -google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20 h1:/CU1zrxTpGylJJbe3Ru94yy6sZRbzALq2/oxl3pGB3U= +google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20/go.mod h1:Tt+08/KdKEt3l8x3Pby3HLQxMB3uk/MzaQ4ZIv0ORTs= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/server/errors.go b/server/errors.go index a29ff27d..7a120e5b 100644 --- a/server/errors.go +++ b/server/errors.go @@ -78,6 +78,21 @@ func (e errHTTP) clone() errHTTP { } } +// errWebSocketPostUpgrade is a wrapper error indicating an error occurred after the WebSocket +// upgrade completed (i.e., the connection was hijacked). This is used to avoid calling +// WriteHeader on hijacked connections, which causes log spam. +type errWebSocketPostUpgrade struct { + err error +} + +func (e *errWebSocketPostUpgrade) Error() string { + return e.err.Error() +} + +func (e *errWebSocketPostUpgrade) Unwrap() error { + return e.err +} + var ( errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil} errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil} diff --git a/server/log.go b/server/log.go index 3d11ac47..72eaf4d1 100644 --- a/server/log.go +++ b/server/log.go @@ -1,14 +1,16 @@ package server import ( + "errors" "fmt" + "net/http" + "strings" + "unicode/utf8" + "github.com/emersion/go-smtp" "github.com/gorilla/websocket" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" - "net/http" - "strings" - "unicode/utf8" ) // Log tags @@ -83,7 +85,8 @@ func httpContext(r *http.Request) log.Context { } func websocketErrorContext(err error) log.Context { - if c, ok := err.(*websocket.CloseError); ok { + var c *websocket.CloseError + if errors.As(err, &c) { return log.Context{ "error": c.Error(), "error_code": c.Code, diff --git a/server/server.go b/server/server.go index 04133dac..ecd05f21 100644 --- a/server/server.go +++ b/server/server.go @@ -434,8 +434,14 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, } else { ev.Info("WebSocket error: %s", err.Error()) } - w.WriteHeader(httpErr.HTTPCode) - return // Do not attempt to write any body to upgraded connection + // Write error response only if the connection was not hijacked yet. Bytes written to hijacked + // connections are WebSocket frames, not HTTP, and will cause "http: response.WriteHeader on hijacked + // connection" log spam. + var postUpgradeErr *errWebSocketPostUpgrade + if !errors.As(err, &postUpgradeErr) { + w.WriteHeader(httpErr.HTTPCode) + } + return } if isNormalError { ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) @@ -1637,7 +1643,10 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Trace("WebSocket connection closed") return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot } - return err + if err != nil { + return &errWebSocketPostUpgrade{err} + } + return nil } func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) { diff --git a/server/server_test.go b/server/server_test.go index 0b125638..aaae563f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -7,6 +7,7 @@ import ( _ "embed" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -3760,3 +3761,61 @@ func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) { } t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack())) } + +// mockResponseWriter is a mock ResponseWriter for testing +type mockResponseWriter struct { + header http.Header + statusCode int + body []byte + writeHeaderHit bool +} + +func newMockResponseWriter() *mockResponseWriter { + return &mockResponseWriter{ + header: make(http.Header), + } +} + +func (m *mockResponseWriter) Header() http.Header { + return m.header +} + +func (m *mockResponseWriter) Write(b []byte) (int, error) { + m.body = append(m.body, b...) + return len(b), nil +} + +func (m *mockResponseWriter) WriteHeader(statusCode int) { + m.statusCode = statusCode + m.writeHeaderHit = true +} + +func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) { + // Test that handleError does not call WriteHeader for WebSocket errors wrapped + // with errWebSocketPostUpgrade (indicating the connection was hijacked) + s := newTestServer(t, newTestConfig(t)) + + // Create a WebSocket upgrade request + r, _ := http.NewRequest("GET", "/mytopic/ws", nil) + r.Header.Set("Upgrade", "websocket") + r.Header.Set("Connection", "Upgrade") + v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("1.2.3.4"), nil) + + // Test post-upgrade errors wrapped with errWebSocketPostUpgrade (should NOT call WriteHeader) + postUpgradeErr := &errWebSocketPostUpgrade{errors.New("websocket: close 1000 (normal)")} + mock := newMockResponseWriter() + s.handleError(mock, r, v, postUpgradeErr) + require.False(t, mock.writeHeaderHit, "WriteHeader should not be called for post-upgrade errors") + + // Test pre-upgrade errors (should call WriteHeader) + preUpgradeErrors := []error{ + errHTTPBadRequestWebSocketsUpgradeHeaderMissing, + errHTTPTooManyRequestsLimitSubscriptions, + errHTTPInternalError, + } + for _, err := range preUpgradeErrors { + mock := newMockResponseWriter() + s.handleError(mock, r, v, err) + require.True(t, mock.writeHeaderHit, "WriteHeader should be called for error: %s", err.Error()) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 45fe975b..5a794eca 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -46,9 +46,9 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -60,9 +60,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -70,21 +70,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -108,13 +108,13 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -395,12 +395,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -572,15 +572,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz", - "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -762,9 +762,9 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz", - "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", "dev": true, "license": "MIT", "dependencies": { @@ -977,16 +977,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", - "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.5" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -1013,14 +1013,14 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1247,9 +1247,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz", - "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", "dev": true, "license": "MIT", "dependencies": { @@ -1444,13 +1444,13 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz", - "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz", + "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", + "@babel/compat-data": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -1464,7 +1464,7 @@ "@babel/plugin-syntax-import-attributes": "^7.28.6", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.6", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", "@babel/plugin-transform-async-to-generator": "^7.28.6", "@babel/plugin-transform-block-scoped-functions": "^7.27.1", "@babel/plugin-transform-block-scoping": "^7.28.6", @@ -1475,7 +1475,7 @@ "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-dotall-regex": "^7.28.6", "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-dynamic-import": "^7.27.1", "@babel/plugin-transform-explicit-resource-management": "^7.28.6", "@babel/plugin-transform-exponentiation-operator": "^7.28.6", @@ -1488,9 +1488,9 @@ "@babel/plugin-transform-member-expression-literals": "^7.27.1", "@babel/plugin-transform-modules-amd": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-new-target": "^7.27.1", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-numeric-separator": "^7.28.6", @@ -1502,7 +1502,7 @@ "@babel/plugin-transform-private-methods": "^7.28.6", "@babel/plugin-transform-private-property-in-object": "^7.28.6", "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.6", + "@babel/plugin-transform-regenerator": "^7.29.0", "@babel/plugin-transform-regexp-modifiers": "^7.28.6", "@babel/plugin-transform-reserved-words": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", @@ -1515,10 +1515,10 @@ "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "core-js-compat": "^3.43.0", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "engines": { @@ -1567,17 +1567,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -1585,9 +1585,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2309,9 +2309,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2798,9 +2798,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", - "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -2812,9 +2812,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", - "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -2826,9 +2826,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", - "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -2840,9 +2840,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", - "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -2854,9 +2854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", - "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -2868,9 +2868,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", - "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -2882,9 +2882,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", - "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -2896,9 +2896,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", - "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -2910,9 +2910,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", - "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -2924,9 +2924,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", - "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -2938,9 +2938,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", - "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "cpu": [ "loong64" ], @@ -2952,9 +2952,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", - "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -2966,9 +2966,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", - "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "cpu": [ "ppc64" ], @@ -2980,9 +2980,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", - "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -2994,9 +2994,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", - "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -3008,9 +3008,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", - "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -3022,9 +3022,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", - "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -3036,9 +3036,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", - "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -3050,9 +3050,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", - "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -3064,9 +3064,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", - "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "cpu": [ "x64" ], @@ -3078,9 +3078,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", - "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -3092,9 +3092,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", - "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -3106,9 +3106,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", - "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -3120,9 +3120,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", - "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -3134,9 +3134,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", - "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -3248,9 +3248,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", - "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "version": "19.2.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.11.tgz", + "integrity": "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==", "license": "MIT", "peer": true, "dependencies": { @@ -3658,14 +3658,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", + "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" + "@babel/helper-define-polyfill-provider": "^0.6.6", + "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3702,9 +3702,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", - "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3823,9 +3823,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", "dev": true, "funding": [ { @@ -4267,9 +4267,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.278", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", - "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true, "license": "ISC" }, @@ -5328,7 +5328,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -7091,9 +7091,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7278,24 +7278,24 @@ } }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-i18next": { @@ -7333,9 +7333,9 @@ } }, "node_modules/react-is": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", - "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, "node_modules/react-refresh": { @@ -7628,9 +7628,9 @@ } }, "node_modules/rollup": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", - "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -7644,31 +7644,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.56.0", - "@rollup/rollup-android-arm64": "4.56.0", - "@rollup/rollup-darwin-arm64": "4.56.0", - "@rollup/rollup-darwin-x64": "4.56.0", - "@rollup/rollup-freebsd-arm64": "4.56.0", - "@rollup/rollup-freebsd-x64": "4.56.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", - "@rollup/rollup-linux-arm-musleabihf": "4.56.0", - "@rollup/rollup-linux-arm64-gnu": "4.56.0", - "@rollup/rollup-linux-arm64-musl": "4.56.0", - "@rollup/rollup-linux-loong64-gnu": "4.56.0", - "@rollup/rollup-linux-loong64-musl": "4.56.0", - "@rollup/rollup-linux-ppc64-gnu": "4.56.0", - "@rollup/rollup-linux-ppc64-musl": "4.56.0", - "@rollup/rollup-linux-riscv64-gnu": "4.56.0", - "@rollup/rollup-linux-riscv64-musl": "4.56.0", - "@rollup/rollup-linux-s390x-gnu": "4.56.0", - "@rollup/rollup-linux-x64-gnu": "4.56.0", - "@rollup/rollup-linux-x64-musl": "4.56.0", - "@rollup/rollup-openbsd-x64": "4.56.0", - "@rollup/rollup-openharmony-arm64": "4.56.0", - "@rollup/rollup-win32-arm64-msvc": "4.56.0", - "@rollup/rollup-win32-ia32-msvc": "4.56.0", - "@rollup/rollup-win32-x64-gnu": "4.56.0", - "@rollup/rollup-win32-x64-msvc": "4.56.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -9331,6 +9331,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -9359,13 +9360,13 @@ "license": "MIT" }, "node_modules/workbox-build/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { "node": "20 || >=22" From b34d23870b486ff04378d9381eb0199dcd0b4b14 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Feb 2026 08:35:38 -0800 Subject: [PATCH 160/548] Release notes --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index aab2500d..2a809fcd 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1683,6 +1683,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** +* Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting) * Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting) * Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting) * Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting) From 570b188a88f1d6ced7922b992e61a3b90a3937f5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Feb 2026 09:46:09 -0800 Subject: [PATCH 161/548] Support for templating the priority header --- docs/publish.md | 33 +++++++------ docs/releases.md | 4 ++ server/server.go | 88 ++++++++++++++++++++------------- server/server_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++ server/types.go | 8 +-- 5 files changed, 194 insertions(+), 50 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 3c8cbdcc..8b267150 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2643,7 +2643,7 @@ You can enable templating by setting the `X-Template` header (or its aliases `Te will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`). See [custom templates](#custom-templates) for more details. * **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`) - will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template. + will enable inline templating, which means that the `message`, `title`, and/or `priority` will be parsed as a Go template. See [inline templating](#inline-templating) for more details. To learn the basics of Go's templating language, please see [template syntax](#template-syntax). @@ -2686,7 +2686,7 @@ and set the `X-Template` header or query parameter to the name of the template f For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or the query parameter `?template=myapp` to use it. -Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys, +Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title`, `message`, and `priority` keys, which are interpreted as Go templates. Here's an **example custom template**: @@ -2704,6 +2704,11 @@ Here's an **example custom template**: Status: {{ .status }} Type: {{ .type | upper }} ({{ .percent }}%) Server: {{ .server }} + priority: | + {{ if gt .percent 90.0 }}5 + {{ else if gt .percent 75.0 }}4 + {{ else }}3 + {{ end }} ``` Once you have the template file in place, you can send the payload to your topic using the `X-Template` @@ -2785,7 +2790,7 @@ Which will result in a notification that looks like this: ### Inline templating -When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your +When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message`, `title`, and `priority` fields of your webhook payload. Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh). @@ -2841,12 +2846,12 @@ Here's an **easier example with a shorter JSON payload**: curl \ --globoff \ -d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \ - 'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}' + 'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}' ``` === "HTTP" ``` http - POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}} HTTP/1.1 + POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}} HTTP/1.1 Host: ntfy.sh {"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}} @@ -2854,7 +2859,7 @@ Here's an **easier example with a shorter JSON payload**: === "JavaScript" ``` javascript - fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}', { + fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}', { method: 'POST', body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' }) @@ -2863,7 +2868,7 @@ Here's an **easier example with a shorter JSON payload**: === "Go" ``` go body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}` - uri := "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" + uri := `https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if eq .error.level "severe"}}5{{else}}3{{end}}` req, _ := http.NewRequest("POST", uri, strings.NewReader(body)) http.DefaultClient.Do(req) ``` @@ -2873,7 +2878,7 @@ Here's an **easier example with a shorter JSON payload**: ``` powershell $Request = @{ Method = "POST" - URI = "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" + URI = 'https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}' Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' ContentType = "application/json" } @@ -2883,14 +2888,14 @@ Here's an **easier example with a shorter JSON payload**: === "Python" ``` python requests.post( - "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", + 'https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}', data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' ) ``` === "PHP" ``` php-inline - file_get_contents("https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", false, stream_context_create([ + file_get_contents('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}', false, stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: application/json", @@ -2899,9 +2904,9 @@ Here's an **easier example with a shorter JSON payload**: ])); ``` -This example uses the `message`/`m` and `title`/`t` query parameters, but obviously this also works with the corresponding -`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message -`Error message: Disk has run out of space`. +This example uses the `message`/`m`, `title`/`t`, and `priority`/`p` query parameters, but obviously this also works with the +corresponding headers. It will send a notification with a title `phil-pc: A severe error has occurred`, a message +`Error message: Disk has run out of space`, and priority `5` (max) if the level is "severe", or `3` (default) otherwise. ### Template syntax ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful, @@ -2920,7 +2925,7 @@ your templates there first ([example for Grafana alert](https://repeatit.io/#/sh ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig), thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload. -Below are the functions that are available to use inside your message/title templates. +Below are the functions that are available to use inside your message, title, and priority templates. * [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc. * [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc. diff --git a/docs/releases.md b/docs/releases.md index 2a809fcd..fccf9317 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1681,6 +1681,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ### ntfy server v2.17.x (UNRELEASED) +**Features:** + +* Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting) + **Bug fixes + maintenance:** * Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting) diff --git a/server/server.go b/server/server.go index ecd05f21..4e03ef5a 100644 --- a/server/server.go +++ b/server/server.go @@ -793,7 +793,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, err } m := newDefaultMessage(t.ID, "") - cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m) + cache, firebase, email, call, template, unifiedpush, priorityStr, e := s.parsePublishParams(r, m) if e != nil { return nil, e.With(t) } @@ -824,7 +824,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if cache { m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() } - if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil { + if err := s.handlePublishBody(r, v, m, body, template, unifiedpush, priorityStr); err != nil { return nil, err } if m.Message == "" { @@ -1055,11 +1055,11 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, priorityStr string, err *errHTTP) { if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) { pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path) if err != nil { - return false, false, "", "", "", false, err + return false, false, "", "", "", false, "", err } m.SequenceID = pathSequenceID } else { @@ -1068,7 +1068,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if sequenceIDRegex.MatchString(sequenceID) { m.SequenceID = sequenceID } else { - return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid + return false, false, "", "", "", false, "", errHTTPBadRequestSequenceIDInvalid } } else { m.SequenceID = m.ID @@ -1089,7 +1089,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if attach != "" { if !urlRegex.MatchString(attach) { - return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", "", "", false, "", errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -1107,19 +1107,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", "", false, "", errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if s.smtpSender == nil && email != "" { - return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled + return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { - return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled + return false, false, "", "", "", false, "", errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { - return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid + return false, false, "", "", "", false, "", errHTTPBadRequestPhoneNumberInvalid } template = templateMode(readParam(r, "x-template", "template", "tpl")) messageStr := readParam(r, "x-message", "message", "m") @@ -1131,29 +1131,33 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi m.Message = messageStr } var e error - m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) - if e != nil { - return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid + priorityStr = readParam(r, "x-priority", "priority", "prio", "p") + if !template.Enabled() { + m.Priority, e = util.ParsePriority(priorityStr) + if e != nil { + return false, false, "", "", "", false, "", errHTTPBadRequestPriorityInvalid + } + priorityStr = "" // Clear since it's already parsed } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache + return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } if call != "" { - return false, false, "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) + return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", "", false, "", errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() { - return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall + return false, false, "", "", "", false, "", errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() { - return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", "", false, "", errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } @@ -1161,7 +1165,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if actionsStr != "" { m.Actions, e = parseActions(actionsStr) if e != nil { - return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error()) + return false, false, "", "", "", false, "", errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error()) } } contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") @@ -1180,7 +1184,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi cache = false email = "" } - return cache, firebase, email, call, template, unifiedpush, nil + return cache, firebase, email, call, template, unifiedpush, priorityStr, nil } // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. @@ -1199,7 +1203,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // 7. curl -T file.txt ntfy.sh/mytopic // In all other cases, mostly if file.txt is > message limit, treat it as an attachment -func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool) error { +func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) } else if unifiedpush { @@ -1209,7 +1213,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body } else if m.Attachment != nil && m.Attachment.Name != "" { return s.handleBodyAsAttachment(r, v, m, body) // Case 4 } else if template.Enabled() { - return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5 + return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5 } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { return s.handleBodyAsTextMessage(m, body) // Case 6 } @@ -1245,7 +1249,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser return nil } -func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error { +func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error { body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit)) if err != nil { return err @@ -1258,7 +1262,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM return err } } else { - if err := s.renderTemplateFromParams(m, peekedBody); err != nil { + if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil { return err } } @@ -1289,33 +1293,51 @@ func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody str } var err error if tpl.Message != nil { - if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil { + if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil { return err } } if tpl.Title != nil { - if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil { + if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil { return err } } + if tpl.Priority != nil { + renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody) + if err != nil { + return err + } + if m.Priority, err = util.ParsePriority(renderedPriority); err != nil { + return errHTTPBadRequestPriorityInvalid + } + } return nil } // renderTemplateFromParams transforms the JSON message body according to the inline template in the -// message and title parameters. -func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error { +// message, title, and priority parameters. +func (s *Server) renderTemplateFromParams(m *message, peekedBody string, priorityStr string) error { var err error - if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil { + if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil { return err } - if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil { + if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil { return err } + if priorityStr != "" { + renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody) + if err != nil { + return err + } + if m.Priority, err = util.ParsePriority(renderedPriority); err != nil { + return errHTTPBadRequestPriorityInvalid + } + } return nil } // renderTemplate renders a template with the given JSON source data. -func (s *Server) renderTemplate(tpl string, source string) (string, error) { +func (s *Server) renderTemplate(name, tpl, source string) (string, error) { if templateDisallowedRegex.MatchString(tpl) { return "", errHTTPBadRequestTemplateDisallowedFunctionCalls } @@ -1330,7 +1352,7 @@ func (s *Server) renderTemplate(tpl string, source string) (string, error) { var buf bytes.Buffer limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes)) if err := t.Execute(limitWriter, data); err != nil { - return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) + return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error()) } return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines } diff --git a/server/server_test.go b/server/server_test.go index aaae563f..a0eb14c4 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3290,6 +3290,117 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) { require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") } +func TestServer_MessageTemplate_Priority(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"priority":"5"}`, map[string]string{ + "X-Message": "Test message", + "X-Priority": "{{.priority}}", + "X-Template": "1", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Test message", m.Message) + require.Equal(t, 5, m.Priority) +} + +func TestServer_MessageTemplate_Priority_Conditional(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + + // Test with error status -> priority 5 + response := request(t, s, "PUT", "/mytopic", `{"status":"Error","message":"Something went wrong"}`, map[string]string{ + "X-Message": "Status: {{.status}} - {{.message}}", + "X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`, + "X-Template": "1", + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Status: Error - Something went wrong", m.Message) + require.Equal(t, 5, m.Priority) + + // Test with success status -> priority 3 + response = request(t, s, "PUT", "/mytopic", `{"status":"Success","message":"All good"}`, map[string]string{ + "X-Message": "Status: {{.status}} - {{.message}}", + "X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`, + "X-Template": "1", + }) + require.Equal(t, 200, response.Code) + m = toMessage(t, response.Body.String()) + require.Equal(t, "Status: Success - All good", m.Message) + require.Equal(t, 3, m.Priority) +} + +func TestServer_MessageTemplate_Priority_NamedValue(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"severity":"high"}`, map[string]string{ + "X-Message": "Alert", + "X-Priority": "{{.severity}}", + "X-Template": "1", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, 4, m.Priority) // "high" = 4 +} + +func TestServer_MessageTemplate_Priority_Invalid(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"priority":"invalid"}`, map[string]string{ + "X-Message": "Test message", + "X-Priority": "{{.priority}}", + "X-Template": "1", + }) + + require.Equal(t, 400, response.Code) + require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_MessageTemplate_Priority_QueryParam(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?template=1&priority={{.priority}}", `{"priority":"max"}`, nil) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, 5, m.Priority) // "max" = 5 +} + +func TestServer_MessageTemplate_Priority_FromTemplateFile(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.TemplateDir = t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "priority-test.yml"), []byte(` +title: "{{.title}}" +message: "{{.message}}" +priority: '{{if eq .level "critical"}}5{{else if eq .level "warning"}}4{{else}}3{{end}}' +`), 0644)) + s := newTestServer(t, c) + + // Test with critical level + response := request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"System down","level":"critical"}`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Alert", m.Title) + require.Equal(t, "System down", m.Message) + require.Equal(t, 5, m.Priority) + + // Test with warning level + response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"High load","level":"warning"}`, nil) + require.Equal(t, 200, response.Code) + m = toMessage(t, response.Body.String()) + require.Equal(t, 4, m.Priority) + + // Test with info level + response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"All good","level":"info"}`, nil) + require.Equal(t, 200, response.Code) + m = toMessage(t, response.Body.String()) + require.Equal(t, 3, m.Priority) +} + func TestServer_DeleteMessage(t *testing.T) { t.Parallel() s := newTestServer(t, newTestConfig(t)) diff --git a/server/types.go b/server/types.go index fae44e5b..3494f570 100644 --- a/server/types.go +++ b/server/types.go @@ -299,7 +299,7 @@ func (t templateMode) FileName() string { return "" } -// templateFile represents a template file with title and message +// templateFile represents a template file with title, message, and priority // It is used for file-based templates, e.g. grafana, influxdb, etc. // // Example YAML: @@ -308,9 +308,11 @@ func (t templateMode) FileName() string { // message: | // This is a {{ .Type }} alert. // It can be multiline. +// priority: '{{ if eq .status "Error" }}5{{ else }}3{{ end }}' type templateFile struct { - Title *string `yaml:"title"` - Message *string `yaml:"message"` + Title *string `yaml:"title"` + Message *string `yaml:"message"` + Priority *string `yaml:"priority"` } type apiHealthResponse struct { From a1da18b99f950c25040db77976465ef021aeb63a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Feb 2026 17:52:24 -0800 Subject: [PATCH 162/548] Show last notification time for UnifiedPush subscriptions --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index fccf9317..22999039 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1672,6 +1672,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring) * Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting) * Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582)) +* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting) **Bug fixes + maintenance:** From 6978fa69a8f7368f1ab86da73f6919c66de3cf72 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Feb 2026 18:32:08 -0800 Subject: [PATCH 163/548] Add better F-Droid logo --- README.md | 6 + docs/index.md | 2 +- docs/releases.md | 1 + docs/static/img/badge-fdroid.svg | 240 +++++++++++++++++++++++++++++++ docs/subscribe/phone.md | 2 +- 5 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 docs/static/img/badge-fdroid.svg diff --git a/README.md b/README.md index 2d888468..caf27bb1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). +

    + + + +

    +

    diff --git a/docs/index.md b/docs/index.md index 307463ed..6deab9e6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c ## Step 1: Get the app - + To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid. diff --git a/docs/releases.md b/docs/releases.md index 22999039..a0c0ad8e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1698,3 +1698,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Refactor: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan)) * Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) * Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) +* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting) diff --git a/docs/static/img/badge-fdroid.svg b/docs/static/img/badge-fdroid.svg new file mode 100644 index 00000000..3a838fb4 --- /dev/null +++ b/docs/static/img/badge-fdroid.svg @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/subscribe/phone.md b/docs/subscribe/phone.md index b8bc5536..92abf082 100644 --- a/docs/subscribe/phone.md +++ b/docs/subscribe/phone.md @@ -5,7 +5,7 @@ on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https contribute, or [build your own](../develop.md). - + You can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy), From 7b0eb3d4670be0d46fecc5a9dddc22e8acb75d65 Mon Sep 17 00:00:00 2001 From: Yaron Shahrabani Date: Thu, 5 Feb 2026 08:42:54 +0100 Subject: [PATCH 164/548] Translated using Weblate (Hebrew) Currently translated at 12.2% (50 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/he/ --- web/public/static/langs/he.json | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/he.json b/web/public/static/langs/he.json index b525092c..4674c993 100644 --- a/web/public/static/langs/he.json +++ b/web/public/static/langs/he.json @@ -31,5 +31,22 @@ "action_bar_clear_notifications": "לפנות את כל ההתראות", "action_bar_mute_notifications": "השתקת התראות", "action_bar_unmute_notifications": "ביטול השתקת התראות", - "action_bar_unsubscribe": "ביטול מינוי" + "action_bar_unsubscribe": "ביטול מינוי", + "notifications_list_item": "התראה", + "notifications_mark_read": "סימון כנקראה", + "notifications_delete": "מחיקה", + "notifications_copied_to_clipboard": "הועתקה ללוח הגזירים", + "notifications_tags": "תגיות", + "notifications_priority_x": "עדיפות {{priority}}", + "notifications_new_indicator": "התראה חדשה", + "notifications_attachment_copy_url_button": "העתקת כתובת", + "notifications_attachment_open_title": "מעבר אל {{url}}", + "notifications_attachment_open_button": "פתיחת צרופה", + "notifications_attachment_link_expires": "תוקף הקישור פג ב־{{date}}", + "notifications_attachment_link_expired": "תוקף קישור ההורדה פג", + "notifications_actions_failed_notification": "פעולה לא מוצלחת", + "notifications_none_for_topic_title": "לא קיבלת התראות בנושא הזה עדיין.", + "notifications_none_for_topic_description": "כדי לשלוח התראות לנושא הזה, צריך לשלוח PUT או POST לכתובת הנושא הזה.", + "notifications_none_for_any_title": "לא קיבלת התראות כלל.", + "notifications_no_subscriptions_title": "נראה שלא נרשמת למינויים עדיין." } From 1b554d5b08086cc8c69e53d99161c1d3c38ba608 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 5 Feb 2026 09:40:18 -0800 Subject: [PATCH 165/548] Web: Fix `clear=true` on action buttons not clearing the notification --- docs/releases.md | 1 + web/public/sw.js | 25 +++++++++++++++++++++---- web/src/app/notificationUtils.js | 2 ++ web/src/components/Notifications.jsx | 21 ++++++++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index a0c0ad8e..835407bf 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1688,6 +1688,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** +* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting) * Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting) * Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting) * Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting) diff --git a/web/public/sw.js b/web/public/sw.js index c8808145..6f807c24 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -237,8 +237,24 @@ const handleClick = async (event) => { if (event.action) { const action = event.notification.data.message.actions.find(({ label }) => event.action === label); + // Helper to clear notification and mark as read + const clearNotification = async () => { + event.notification.close(); + const { subscriptionId, message: msg } = event.notification.data; + const seqId = msg.sequence_id || msg.id; + if (subscriptionId && seqId) { + const db = await dbAsync(); + await db.notifications.where({ subscriptionId, sequenceId: seqId }).modify({ new: 0 }); + const badgeCount = await db.notifications.where({ new: 1 }).count(); + self.navigator.setAppBadge?.(badgeCount); + } + }; + if (action.action === "view") { self.clients.openWindow(action.url); + if (action.clear) { + await clearNotification(); + } } else if (action.action === "http") { try { const response = await fetch(action.url, { @@ -250,6 +266,11 @@ const handleClick = async (event) => { if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`); } + + // Only clear on success + if (action.clear) { + await clearNotification(); + } } catch (e) { console.error("[ServiceWorker] Error performing http action", e); self.registration.showNotification(`${t("notifications_actions_failed_notification")}: ${action.label} (${action.action})`, { @@ -259,10 +280,6 @@ const handleClick = async (event) => { }); } } - - if (action.clear) { - event.notification.close(); - } } else if (message.click) { self.clients.openWindow(message.click); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index a3025f67..eb47cb97 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -60,6 +60,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUr const image = isImage(message.attachment) ? message.attachment.url : undefined; const sequenceId = message.sequence_id || message.id; const tag = notificationTag(baseUrl, topic, sequenceId); + const subscriptionId = `${baseUrl}/${topic}`; // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API return [ @@ -75,6 +76,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUr silent: false, // This is used by the notification onclick event data: { + subscriptionId, message, topicRoute, }, diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index b7be8de2..3ee79c16 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -39,6 +39,7 @@ import { import { formatMessage, formatTitle, isImage } from "../app/notificationUtils"; import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles"; import subscriptionManager from "../app/SubscriptionManager"; +import notifier from "../app/Notifier"; import priority1 from "../img/priority-1.svg"; import priority2 from "../img/priority-2.svg"; import priority4 from "../img/priority-4.svg"; @@ -508,6 +509,15 @@ const updateActionStatus = (notification, action, progress, error) => { }); }; +const clearNotification = async (notification) => { + console.log(`[Notifications] Clearing notification ${notification.id}`); + const subscription = await subscriptionManager.get(notification.subscriptionId); + if (subscription) { + await notifier.cancel(subscription, notification); + } + await subscriptionManager.markNotificationRead(notification.id); +}; + const performHttpAction = async (notification, action) => { console.log(`[Notifications] Performing HTTP user action`, action); try { @@ -523,6 +533,9 @@ const performHttpAction = async (notification, action) => { const success = response.status >= 200 && response.status <= 299; if (success) { updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); + if (action.clear) { + await clearNotification(notification); + } } else { updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); } @@ -548,10 +561,16 @@ const UserAction = (props) => { ); } if (action.action === "view") { + const handleClick = () => { + openUrl(action.url); + if (action.clear) { + clearNotification(notification); + } + }; return ( + + ); + } return null; // Others }; diff --git a/web/src/registerSW.js b/web/src/registerSW.js index 842cf80e..fa90af19 100644 --- a/web/src/registerSW.js +++ b/web/src/registerSW.js @@ -12,6 +12,15 @@ const registerSW = () => { return; } + // Listen for messages from the service worker (e.g., "copy" action) + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type === "copy" && event.data?.value) { + navigator.clipboard?.writeText(event.data.value).catch((e) => { + console.error("[ServiceWorker] Failed to copy to clipboard", e); + }); + } + }); + viteRegisterSW({ onRegisteredSW(swUrl, registration) { console.log("[ServiceWorker] Registered:", { swUrl, registration }); From cfdc364e3fc561781a085fac4efece2b9239d15b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Feb 2026 14:28:27 -0500 Subject: [PATCH 173/548] Version API endpoint --- docs/releases.md | 1 + server/server.go | 3 +++ server/server_admin.go | 8 ++++++++ server/server_admin_test.go | 36 ++++++++++++++++++++++++++++++++++++ server/types.go | 6 ++++++ 5 files changed, 54 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index cd16054a..08439c27 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1686,6 +1686,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** * Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting) +* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting) * Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting) **Bug fixes + maintenance:** diff --git a/server/server.go b/server/server.go index 820913e9..bf982503 100644 --- a/server/server.go +++ b/server/server.go @@ -90,6 +90,7 @@ var ( matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" apiHealthPath = "/v1/health" + apiVersionPath = "/v1/version" apiConfigPath = "/v1/config" apiStatsPath = "/v1/stats" apiWebPushPath = "/v1/webpush" @@ -467,6 +468,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { return s.handleHealth(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath { + return s.ensureAdmin(s.handleVersion)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath { return s.handleConfig(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { diff --git a/server/server_admin.go b/server/server_admin.go index b724d4b7..2560796f 100644 --- a/server/server_admin.go +++ b/server/server_admin.go @@ -6,6 +6,14 @@ import ( "net/http" ) +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error { + return s.writeJSON(w, &apiVersionResponse{ + Version: s.config.BuildVersion, + Commit: s.config.BuildCommit, + Date: s.config.BuildDate, + }) +} + func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error { users, err := s.userManager.Users() if err != nil { diff --git a/server/server_admin_test.go b/server/server_admin_test.go index 8925702e..cedaed87 100644 --- a/server/server_admin_test.go +++ b/server/server_admin_test.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -9,6 +10,41 @@ import ( "time" ) +func TestVersion_Admin(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BuildVersion = "1.2.3" + c.BuildCommit = "abcdef0" + c.BuildDate = "2026-02-08T00:00:00Z" + s := newTestServer(t, c) + defer s.closeDatabases() + + // Create admin and regular user + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + + // Admin can access /v1/version + rr := request(t, s, "GET", "/v1/version", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + var versionResponse apiVersionResponse + require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse)) + require.Equal(t, "1.2.3", versionResponse.Version) + require.Equal(t, "abcdef0", versionResponse.Commit) + require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date) + + // Non-admin user cannot access /v1/version + rr = request(t, s, "GET", "/v1/version", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) + + // Unauthenticated user cannot access /v1/version + rr = request(t, s, "GET", "/v1/version", "", nil) + require.Equal(t, 401, rr.Code) +} + func TestUser_AddRemove(t *testing.T) { s := newTestServer(t, newTestConfigWithAuthFile(t)) defer s.closeDatabases() diff --git a/server/types.go b/server/types.go index 3494f570..208b4050 100644 --- a/server/types.go +++ b/server/types.go @@ -319,6 +319,12 @@ type apiHealthResponse struct { Healthy bool `json:"healthy"` } +type apiVersionResponse struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` +} + type apiStatsResponse struct { Messages int64 `json:"messages"` MessagesRate float64 `json:"messages_rate"` // Average number of messages per second From 7860f2142c2f4c9ad13ba641f225fab30bbab049 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Feb 2026 15:11:06 -0500 Subject: [PATCH 174/548] Constants --- web/src/app/actions.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 web/src/app/actions.js diff --git a/web/src/app/actions.js b/web/src/app/actions.js new file mode 100644 index 00000000..43775b09 --- /dev/null +++ b/web/src/app/actions.js @@ -0,0 +1,7 @@ +// Action types for ntfy messages +// These correspond to the server action types in server/actions.go + +export const ACTION_VIEW = "view"; +export const ACTION_BROADCAST = "broadcast"; +export const ACTION_HTTP = "http"; +export const ACTION_COPY = "copy"; From 90aeb811ff0428e8486ad736004c14b89c339fff Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Feb 2026 22:18:45 -0500 Subject: [PATCH 175/548] Changelog --- docs/install.md | 60 ++++++++++++++++++++++++------------------------ docs/releases.md | 60 +++++++++++++++++++++++++++--------------------- 2 files changed, 64 insertions(+), 56 deletions(-) diff --git a/docs/install.md b/docs/install.md index 224077b2..9a81f963 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,37 +30,37 @@ deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz - tar zxvf ntfy_2.16.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.16.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.tar.gz + tar zxvf ntfy_2.17.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.17.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz - tar zxvf ntfy_2.16.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.16.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.tar.gz + tar zxvf ntfy_2.17.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.17.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz - tar zxvf ntfy_2.16.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.16.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.tar.gz + tar zxvf ntfy_2.17.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.17.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz - tar zxvf ntfy_2.16.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.16.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.tar.gz + tar zxvf ntfy_2.17.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.17.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -116,7 +116,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -124,7 +124,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -132,7 +132,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -140,7 +140,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -150,28 +150,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.16.0/ntfy_2.16.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.16.0/ntfy_2.16.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.16.0/ntfy_2.16.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -213,18 +213,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.16.0/ntfy_2.16.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.16.0/ntfy_2.16.0_darwin_all.tar.gz > ntfy_2.16.0_darwin_all.tar.gz -tar zxvf ntfy_2.16.0_darwin_all.tar.gz -sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_darwin_all.tar.gz > ntfy_2.17.0_darwin_all.tar.gz +tar zxvf ntfy_2.17.0_darwin_all.tar.gz +sudo cp -a ntfy_2.17.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.16.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.17.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -245,7 +245,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.16.0/ntfy_2.16.0_windows_amd64.zip), +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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` diff --git a/docs/releases.md b/docs/releases.md index ddc82f06..f291bea2 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,45 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.16.0 | Jan 19, 2026 | +| ntfy server | v2.17.0 | Feb 8, 2026 | | ntfy Android app | v1.22.2 | Jan 25, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy server v2.17.0 +Released February 8, 2026 + +This release adds support for templating in the priority field, a new "copy" action button to copy values to the clipboard, +a red notification dot on the favicon for unread messages, and an admin-only version endpoint. It also includes several +crash fixes, web app improvements, and documentation updates. + +❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), +or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy will always remain open source. + +**Features:** + +* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting) +* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting) +* Server/Web: [Support "copy" action](publish.md#copy-to-clipboard) button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting) +* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting) + +**Bug fixes + maintenance:** + +* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting) +* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598)) +* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting) +* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan)) +* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting) +* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting) +* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting) +* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting) +* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR) +* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts)) +* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) +* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) +* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting) + ## ntfy Android app v1.22.2 Released January 20, 2026 @@ -1681,28 +1714,3 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1)) * Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting) * Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting) - -### ntfy server v2.17.x (UNRELEASED) - -**Features:** - -* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting) -* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting) -* Server/Web: Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting) -* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting) - -**Bug fixes + maintenance:** - -* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting) -* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598)) -* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting) -* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan)) -* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting) -* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting) -* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting) -* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting) -* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR) -* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts)) -* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing) -* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths)) -* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting) \ No newline at end of file From 4cd556f5aa79a8c52c3c0b156a9a70ac59d72655 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Feb 2026 22:30:47 -0500 Subject: [PATCH 176/548] Copy fix --- docs/publish.md | 5 +++++ web/public/sw.js | 22 +--------------------- web/src/app/notificationUtils.js | 4 ++-- web/src/registerSW.js | 9 --------- 4 files changed, 8 insertions(+), 32 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 4f68c572..00c43b5e 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2304,6 +2304,11 @@ _Supported on:_ :material-android: :material-firefox: The `copy` action **copies a given value to the clipboard when the action button is tapped**. This is useful for one-time passcodes, tokens, or any other value you want to quickly copy without opening the full notification. +!!! info + The copy action button is only shown in the web app and Android app notification list, **not** in browser desktop + notifications. This is because browsers do not allow clipboard access from notification actions without direct + user interaction with the page. + Here's an example using the [`X-Actions` header](#using-a-header): === "Command line (curl)" diff --git a/web/public/sw.js b/web/public/sw.js index a09cb56e..fa2ac9e0 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -4,7 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing"; import { NetworkFirst } from "workbox-strategies"; import { clientsClaim } from "workbox-core"; import { dbAsync } from "../src/app/db"; -import { ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "../src/app/actions"; +import { ACTION_HTTP, ACTION_VIEW } from "../src/app/actions"; import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; import { @@ -256,26 +256,6 @@ const handleClick = async (event) => { if (action.clear) { await clearNotification(); } - } else if (action.action === ACTION_COPY) { - try { - // Service worker can't access the clipboard API directly, so we try to - // open a focused client and use it, or fall back to opening a window - const allClients = await self.clients.matchAll({ type: "window" }); - const focusedClient = allClients.find((c) => c.focused) || allClients[0]; - if (focusedClient) { - focusedClient.postMessage({ type: "copy", value: action.value }); - } - if (action.clear) { - await clearNotification(); - } - } catch (e) { - console.error("[ServiceWorker] Error performing copy action", e); - self.registration.showNotification(`${t("notifications_actions_failed_notification")}: ${action.label} (${action.action})`, { - body: e.message, - icon, - badge, - }); - } } else if (action.action === ACTION_HTTP) { try { const response = await fetch(action.url, { diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index ed75832c..070f8231 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -2,7 +2,7 @@ // and cannot be used in the service worker import emojisMapped from "./emojisMapped"; -import { ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "./actions"; +import { ACTION_HTTP, ACTION_VIEW } from "./actions"; const toEmojis = (tags) => { if (!tags) return []; @@ -82,7 +82,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUr topicRoute, }, actions: message.actions - ?.filter(({ action }) => action === ACTION_VIEW || action === ACTION_HTTP || action === ACTION_COPY) + ?.filter(({ action }) => action === ACTION_VIEW || action === ACTION_HTTP) .map(({ label }) => ({ action: label, title: label, diff --git a/web/src/registerSW.js b/web/src/registerSW.js index fa90af19..842cf80e 100644 --- a/web/src/registerSW.js +++ b/web/src/registerSW.js @@ -12,15 +12,6 @@ const registerSW = () => { return; } - // Listen for messages from the service worker (e.g., "copy" action) - navigator.serviceWorker.addEventListener("message", (event) => { - if (event.data?.type === "copy" && event.data?.value) { - navigator.clipboard?.writeText(event.data.value).catch((e) => { - console.error("[ServiceWorker] Failed to copy to clipboard", e); - }); - } - }); - viteRegisterSW({ onRegisteredSW(swUrl, registration) { console.log("[ServiceWorker] Registered:", { swUrl, registration }); From bc08819525628aa82557392014fc2d02b711a11b Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:57:09 +0800 Subject: [PATCH 177/548] Allow overriding `npm` binary in Makefile --- Makefile | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 3b9cfdc5..2da5333e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ MAKEFLAGS := --jobs=1 +NPM := npm PYTHON := python3 PIP := pip3 VERSION := $(shell git describe --tag) @@ -137,7 +138,7 @@ web: web-deps web-build web-build: cd web \ - && npm run build \ + && $(NPM) run build \ && mv build/index.html build/app.html \ && rm -rf ../server/site \ && mv build ../server/site \ @@ -145,20 +146,20 @@ web-build: ../server/site/config.js web-deps: - cd web && npm install + cd web && $(NPM) install # If this fails for .svg files, optimize them with svgo web-deps-update: - cd web && npm update + cd web && $(NPM) update web-fmt: - cd web && npm run format + cd web && $(NPM) run format web-fmt-check: - cd web && npm run format:check + cd web && $(NPM) run format:check web-lint: - cd web && npm run lint + cd web && $(NPM) run lint # Main server/client build From 3bd1a1ea031382a38734aae0e77cff8afd217d2f Mon Sep 17 00:00:00 2001 From: Mazurky Date: Thu, 12 Feb 2026 23:06:50 +0100 Subject: [PATCH 178/548] Translated using Weblate (Slovak) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sk/ --- web/public/static/langs/sk.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/sk.json b/web/public/static/langs/sk.json index e8ec46d1..5e670789 100644 --- a/web/public/static/langs/sk.json +++ b/web/public/static/langs/sk.json @@ -406,5 +406,7 @@ "web_push_unknown_notification_title": "Neznáme oznámenie prijaté zo servera", "web_push_unknown_notification_body": "Možno budete musieť aktualizovať ntfy otvorením webovej aplikácie", "alert_notification_permission_required_title": "Oznámenia sú vypnuté", - "alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS" + "alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS", + "account_basics_cannot_edit_or_delete_provisioned_user": "Prideleného používateľa nemožno upraviť ani odstrániť", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Pridelený token nemožno upraviť ani odstrániť" } From b16d381626dff436dfb4a0a51eeba48e21f5b5d3 Mon Sep 17 00:00:00 2001 From: ZebMcKayhan <92390878+ZebMcKayhan@users.noreply.github.com> Date: Sun, 15 Feb 2026 19:41:22 +0100 Subject: [PATCH 179/548] Update integrations.md Added SIA-Server to project list. --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index e8a7d15f..dd9b897f 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -184,6 +184,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go) - [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS) - [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS) +- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python) ## Blog + forum posts From 3e6b46ec0cfe3650c7364e49e8ab33ea2e4717f8 Mon Sep 17 00:00:00 2001 From: luneth <48867138+luneth@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:43:57 +0100 Subject: [PATCH 180/548] Fix: Clarify F-Droid flavor excludes Google Services Updated documentation to reflect that the F-Droid flavor automatically excludes Google Services dependencies [here](https://github.com/binwiederhier/ntfy-android/blob/33a36c4b548a42dbc712eaa9ccaa89da4201a410/app/build.gradle#L82) --- docs/develop.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/develop.md b/docs/develop.md index ecf35e1d..dde20b56 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -340,10 +340,6 @@ Then either follow the steps for building with or without Firebase. Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml) if you're self-hosting the server. Then run: ``` -# Remove Google dependencies (FCM) -sed -i -e '/google-services/d' build.gradle -sed -i -e '/google-services/d' app/build.gradle - # To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk) ./gradlew assembleFdroidRelease @@ -351,6 +347,8 @@ sed -i -e '/google-services/d' app/build.gradle ./gradlew bundleFdroidRelease ``` +The F-Droid flavor automatically excludes Google Services dependencies. + ### Build Play flavor (FCM) !!! info I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will From e432bf2886631654d2203df5d84aa818c581fbb2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 12:13:10 -0500 Subject: [PATCH 181/548] Rename PostgreSQL table prefix from wp_ to webpush_ --- cmd/serve.go | 7 +- docs/config.md | 27 +++- docs/releases.md | 7 + go.mod | 4 + go.sum | 9 ++ server/config.go | 2 + server/server.go | 13 +- server/server.yml | 6 + server/server_webpush.go | 5 +- server/server_webpush_test.go | 14 +- server/types.go | 16 -- server/webpush_store.go | 285 ---------------------------------- server/webpush_store_test.go | 199 ------------------------ webpush/postgres.go | 224 ++++++++++++++++++++++++++ webpush/postgres_test.go | 207 ++++++++++++++++++++++++ webpush/sqlite.go | 280 +++++++++++++++++++++++++++++++++ webpush/sqlite_test.go | 203 ++++++++++++++++++++++++ webpush/store.go | 51 ++++++ 18 files changed, 1041 insertions(+), 518 deletions(-) delete mode 100644 server/webpush_store.go delete mode 100644 server/webpush_store_test.go create mode 100644 webpush/postgres.go create mode 100644 webpush/postgres_test.go create mode 100644 webpush/sqlite.go create mode 100644 webpush/sqlite_test.go create mode 100644 webpush/store.go diff --git a/cmd/serve.go b/cmd/serve.go index b451a118..33dc838d 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -39,6 +39,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), 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.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)"}), @@ -143,6 +144,7 @@ func execServe(c *cli.Context) error { keyFile := c.String("key-file") certFile := c.String("cert-file") firebaseKeyFile := c.String("firebase-key-file") + databaseURL := c.String("database-url") webPushPrivateKey := c.String("web-push-private-key") webPushPublicKey := c.String("web-push-public-key") webPushFile := c.String("web-push-file") @@ -284,8 +286,8 @@ func execServe(c *cli.Context) error { return errors.New("if set, FCM key file must exist") } else if firebaseKeyFile != "" && !server.FirebaseAvailable { return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)") - } else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") { - return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys") + } else if webPushPublicKey != "" && (webPushPrivateKey == "" || (webPushFile == "" && databaseURL == "") || webPushEmailAddress == "" || baseURL == "") { + return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file (or database-url), web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys") } else if keepaliveInterval < 5*time.Second { return errors.New("keepalive interval cannot be lower than five seconds") } else if managerInterval < 5*time.Second { @@ -494,6 +496,7 @@ func execServe(c *cli.Context) error { conf.EnableMetrics = enableMetrics conf.MetricsListenHTTP = metricsListenHTTP conf.ProfileListenHTTP = profileListenHTTP + conf.DatabaseURL = databaseURL conf.WebPushPrivateKey = webPushPrivateKey conf.WebPushPublicKey = webPushPublicKey conf.WebPushFile = webPushFile diff --git a/docs/config.md b/docs/config.md index 8a125146..f6e27f60 100644 --- a/docs/config.md +++ b/docs/config.md @@ -144,6 +144,20 @@ the message to the subscribers. Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the [`since=` parameter](subscribe/api.md#fetch-cached-messages). +## PostgreSQL database +By default, ntfy uses SQLite for all database-backed stores. As an alternative, you can configure ntfy to use PostgreSQL +by setting the `database-url` option to a PostgreSQL connection string: + +```yaml +database-url: "postgres://user:pass@host:5432/ntfy" +``` + +When `database-url` is set, ntfy will use PostgreSQL for the web push subscription store instead of SQLite. The +`web-push-file` option is not required in this case. Support for PostgreSQL for the message cache and user manager +will be added in future releases. + +You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`. + ## Attachments If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). @@ -1141,12 +1155,15 @@ a database to keep track of the browser's subscriptions, and an admin email addr - `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890 - `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890 -- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` +- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` (not required if `database-url` is set) - `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com` - `web-push-startup-queries` is an optional list of queries to run on startup` - `web-push-expiry-warning-duration` defines the duration after which unused subscriptions are sent a warning (default is `55d`) - `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`) +Alternatively, you can use PostgreSQL instead of SQLite for the web push subscription store by setting `database-url` +(see [PostgreSQL database](#postgresql-database)). + Limitations: - Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_ @@ -1172,9 +1189,10 @@ web-push-file: /var/cache/ntfy/webpush.db web-push-email-address: sysadmin@example.com ``` -The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 55 days, -and will automatically expire after 60 days (default). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed), -subscriptions are also removed automatically. +The `web-push-file` is used to store the push subscriptions in a local SQLite database. Alternatively, if `database-url` +is set, subscriptions are stored in PostgreSQL and `web-push-file` is not required. Unused subscriptions will send out +a warning after 55 days, and will automatically expire after 60 days (default). If the gateway returns an error +(e.g. 410 Gone when a user has unsubscribed), subscriptions are also removed automatically. The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then. @@ -1755,6 +1773,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | | `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | | `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). | +| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for database-backed stores instead of SQLite. Currently applies to the web push store. See [PostgreSQL database](#postgresql-database). | | `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If 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. See [message cache](#message-cache). | | `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | | `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | diff --git a/docs/releases.md b/docs/releases.md index f291bea2..fd71cc0d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1714,3 +1714,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1)) * Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting) * Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting) +* Fix crash in settings when fragment is detached during backup/restore or log operations + +### ntfy server v2.12.x (UNRELEASED) + +**Features:** + +* Add PostgreSQL as an alternative database backend for the web push subscription store via `database-url` config option diff --git a/go.mod b/go.mod index 992aced8..26c308c9 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.19.0 github.com/SherClockHolmes/webpush-go v1.4.0 + github.com/jackc/pgx/v5 v5.8.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 @@ -71,6 +72,9 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.17.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 + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index c42210b4..eb0e7d12 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,14 @@ 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= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -144,6 +152,7 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= diff --git a/server/config.go b/server/config.go index 278b6aed..85a2c2b2 100644 --- a/server/config.go +++ b/server/config.go @@ -88,6 +88,7 @@ var ( // Config is the main config struct for the application. Use New to instantiate a default config struct. type Config struct { File string // Config file, only used for testing + DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy") BaseURL string ListenHTTP string ListenHTTPS string @@ -192,6 +193,7 @@ type Config struct { func NewConfig() *Config { return &Config{ File: DefaultConfigFile, // Only used for testing + DatabaseURL: "", BaseURL: "", ListenHTTP: DefaultListenHTTP, ListenHTTPS: "", diff --git a/server/server.go b/server/server.go index bf982503..cba0179a 100644 --- a/server/server.go +++ b/server/server.go @@ -37,6 +37,7 @@ import ( "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util/sprig" + "heckel.io/ntfy/v2/webpush" ) // Server is the main server, providing the UI and API for ntfy @@ -57,7 +58,7 @@ type Server struct { messagesHistory []int64 // Last n values of the messages counter, used to determine rate userManager *user.Manager // Might be nil! messageCache *messageCache // Database that stores the messages - webPush *webPushStore // Database that stores web push subscriptions + webPush webpush.Store // Database that stores web push subscriptions fileCache *fileCache // File system based cache that stores attachments stripe stripeAPI // Stripe API, can be replaced with a mock priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) @@ -176,9 +177,13 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } - var webPush *webPushStore + var wp webpush.Store if conf.WebPushPublicKey != "" { - webPush, err = newWebPushStore(conf.WebPushFile, conf.WebPushStartupQueries) + if conf.DatabaseURL != "" { + wp, err = webpush.NewPostgresStore(conf.DatabaseURL) + } else { + wp, err = webpush.NewSQLiteStore(conf.WebPushFile, conf.WebPushStartupQueries) + } if err != nil { return nil, err } @@ -233,7 +238,7 @@ func New(conf *Config) (*Server, error) { s := &Server{ config: conf, messageCache: messageCache, - webPush: webPush, + webPush: wp, fileCache: fileCache, firebaseClient: firebaseClient, smtpSender: mailer, diff --git a/server/server.yml b/server/server.yml index 598c41bc..63728ae2 100644 --- a/server/server.yml +++ b/server/server.yml @@ -38,6 +38,12 @@ # # firebase-key-file: +# If "database-url" is set, ntfy will use PostgreSQL for database-backed stores instead of SQLite. +# Currently this applies to the web push subscription store. Message cache and user manager support +# will be added in future releases. When set, the "web-push-file" option is not required. +# +# database-url: "postgres://user:pass@host:5432/ntfy" + # 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. # diff --git a/server/server_webpush.go b/server/server_webpush.go index d3f09bd9..11e37f66 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -12,6 +12,7 @@ import ( "github.com/SherClockHolmes/webpush-go" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/user" + wpush "heckel.io/ntfy/v2/webpush" ) const ( @@ -128,7 +129,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error { if err != nil { return err } - warningSent := make([]*webPushSubscription, 0) + warningSent := make([]*wpush.Subscription, 0) for _, subscription := range subscriptions { if err := s.sendWebPushNotification(subscription, payload); err != nil { log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning") @@ -143,7 +144,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error { return nil } -func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error { +func (s *Server) sendWebPushNotification(sub *wpush.Subscription, message []byte, contexters ...log.Contexter) error { log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message") payload := &webpush.Subscription{ Endpoint: sub.Endpoint, diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go index f116103a..fe77787e 100644 --- a/server/server_webpush_test.go +++ b/server/server_webpush_test.go @@ -5,10 +5,6 @@ package server import ( "encoding/json" "fmt" - "github.com/SherClockHolmes/webpush-go" - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" "io" "net/http" "net/http/httptest" @@ -18,6 +14,12 @@ import ( "sync/atomic" "testing" "time" + + "github.com/SherClockHolmes/webpush-go" + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" + wpush "heckel.io/ntfy/v2/webpush" ) const ( @@ -238,7 +240,7 @@ func TestServer_WebPush_Expiry(t *testing.T) { addSubscription(t, s, pushService.URL+"/push-receive", "test-topic") requireSubscriptionCount(t, s, "test-topic", 1) - _, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix()) + _, err := s.webPush.(*wpush.SQLiteStore).DB().Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix()) require.Nil(t, err) s.pruneAndNotifyWebPushSubscriptions() @@ -248,7 +250,7 @@ func TestServer_WebPush_Expiry(t *testing.T) { return received.Load() }) - _, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix()) + _, err = s.webPush.(*wpush.SQLiteStore).DB().Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix()) require.Nil(t, err) s.pruneAndNotifyWebPushSubscriptions() diff --git a/server/types.go b/server/types.go index a710a183..dfd797e5 100644 --- a/server/types.go +++ b/server/types.go @@ -593,22 +593,6 @@ func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload { } } -type webPushSubscription struct { - ID string - Endpoint string - Auth string - P256dh string - UserID string -} - -func (w *webPushSubscription) Context() log.Context { - return map[string]any{ - "web_push_subscription_id": w.ID, - "web_push_subscription_user_id": w.UserID, - "web_push_subscription_endpoint": w.Endpoint, - } -} - // https://developer.mozilla.org/en-US/docs/Web/Manifest type webManifestResponse struct { Name string `json:"name"` diff --git a/server/webpush_store.go b/server/webpush_store.go deleted file mode 100644 index db0304be..00000000 --- a/server/webpush_store.go +++ /dev/null @@ -1,285 +0,0 @@ -package server - -import ( - "database/sql" - "errors" - "heckel.io/ntfy/v2/util" - "net/netip" - "time" - - _ "github.com/mattn/go-sqlite3" // SQLite driver -) - -const ( - subscriptionIDPrefix = "wps_" - subscriptionIDLength = 10 - subscriptionEndpointLimitPerSubscriberIP = 10 -) - -var ( - errWebPushNoRows = errors.New("no rows found") - errWebPushTooManySubscriptions = errors.New("too many subscriptions") - errWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty") -) - -const ( - createWebPushSubscriptionsTableQuery = ` - BEGIN; - CREATE TABLE IF NOT EXISTS subscription ( - id TEXT PRIMARY KEY, - endpoint TEXT NOT NULL, - key_auth TEXT NOT NULL, - key_p256dh TEXT NOT NULL, - user_id TEXT NOT NULL, - subscriber_ip TEXT NOT NULL, - updated_at INT NOT NULL, - warned_at INT NOT NULL DEFAULT 0 - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint); - CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip); - CREATE TABLE IF NOT EXISTS subscription_topic ( - subscription_id TEXT NOT NULL, - topic TEXT NOT NULL, - PRIMARY KEY (subscription_id, topic), - FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic); - CREATE TABLE IF NOT EXISTS schemaVersion ( - id INT PRIMARY KEY, - version INT NOT NULL - ); - COMMIT; - ` - builtinStartupQueries = ` - PRAGMA foreign_keys = ON; - ` - - selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?` - selectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?` - selectWebPushSubscriptionsForTopicQuery = ` - SELECT id, endpoint, key_auth, key_p256dh, user_id - FROM subscription_topic st - JOIN subscription s ON s.id = st.subscription_id - WHERE st.topic = ? - ORDER BY endpoint - ` - selectWebPushSubscriptionsExpiringSoonQuery = ` - SELECT id, endpoint, key_auth, key_p256dh, user_id - FROM subscription - WHERE warned_at = 0 AND updated_at <= ? - ` - insertWebPushSubscriptionQuery = ` - INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (endpoint) - DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at - ` - updateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?` - deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?` - deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?` - deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan! - - insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)` - deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?` - deleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)` -) - -// Schema management queries -const ( - currentWebPushSchemaVersion = 1 - insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` -) - -type webPushStore struct { - db *sql.DB -} - -func newWebPushStore(filename, startupQueries string) (*webPushStore, error) { - db, err := sql.Open("sqlite3", filename) - if err != nil { - return nil, err - } - if err := setupWebPushDB(db); err != nil { - return nil, err - } - if err := runWebPushStartupQueries(db, startupQueries); err != nil { - return nil, err - } - return &webPushStore{ - db: db, - }, nil -} - -func setupWebPushDB(db *sql.DB) error { - // If 'schemaVersion' table does not exist, this must be a new database - rows, err := db.Query(selectWebPushSchemaVersionQuery) - if err != nil { - return setupNewWebPushDB(db) - } - return rows.Close() -} - -func setupNewWebPushDB(db *sql.DB) error { - if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil { - return err - } - if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil { - return err - } - return nil -} - -func runWebPushStartupQueries(db *sql.DB, startupQueries string) error { - if _, err := db.Exec(startupQueries); err != nil { - return err - } - if _, err := db.Exec(builtinStartupQueries); err != nil { - return err - } - return nil -} - -// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all -// existing entries for a given endpoint. -func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - // Read number of subscriptions for subscriber IP address - rowsCount, err := tx.Query(selectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String()) - if err != nil { - return err - } - defer rowsCount.Close() - var subscriptionCount int - if !rowsCount.Next() { - return errWebPushNoRows - } - if err := rowsCount.Scan(&subscriptionCount); err != nil { - return err - } - if err := rowsCount.Close(); err != nil { - return err - } - // Read existing subscription ID for endpoint (or create new ID) - rows, err := tx.Query(selectWebPushSubscriptionIDByEndpoint, endpoint) - if err != nil { - return err - } - defer rows.Close() - var subscriptionID string - if rows.Next() { - if err := rows.Scan(&subscriptionID); err != nil { - return err - } - } else { - if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { - return errWebPushTooManySubscriptions - } - subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) - } - if err := rows.Close(); err != nil { - return err - } - // Insert or update subscription - updatedAt, warnedAt := time.Now().Unix(), 0 - if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { - return err - } - // Replace all subscription topics - if _, err := tx.Exec(deleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil { - return err - } - for _, topic := range topics { - if _, err = tx.Exec(insertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil { - return err - } - } - return tx.Commit() -} - -// SubscriptionsForTopic returns all subscriptions for the given topic -func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscription, error) { - rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic) - if err != nil { - return nil, err - } - defer rows.Close() - return c.subscriptionsFromRows(rows) -} - -// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period -func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPushSubscription, error) { - rows, err := c.db.Query(selectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix()) - if err != nil { - return nil, err - } - defer rows.Close() - return c.subscriptionsFromRows(rows) -} - -// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon -func (c *webPushStore) MarkExpiryWarningSent(subscriptions []*webPushSubscription) error { - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, subscription := range subscriptions { - if _, err := tx.Exec(updateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil { - return err - } - } - return tx.Commit() -} - -func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscription, error) { - subscriptions := make([]*webPushSubscription, 0) - for rows.Next() { - var id, endpoint, auth, p256dh, userID string - if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil { - return nil, err - } - subscriptions = append(subscriptions, &webPushSubscription{ - ID: id, - Endpoint: endpoint, - Auth: auth, - P256dh: p256dh, - UserID: userID, - }) - } - return subscriptions, nil -} - -// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint -func (c *webPushStore) RemoveSubscriptionsByEndpoint(endpoint string) error { - _, err := c.db.Exec(deleteWebPushSubscriptionByEndpointQuery, endpoint) - return err -} - -// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID -func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error { - if userID == "" { - return errWebPushUserIDCannotBeEmpty - } - _, err := c.db.Exec(deleteWebPushSubscriptionByUserIDQuery, userID) - return err -} - -// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period -func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error { - _, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix()) - if err != nil { - return err - } - _, err = c.db.Exec(deleteWebPushSubscriptionTopicWithoutSubscription) - return err -} - -// Close closes the underlying database connection -func (c *webPushStore) Close() error { - return c.db.Close() -} diff --git a/server/webpush_store_test.go b/server/webpush_store_test.go deleted file mode 100644 index ab5bc424..00000000 --- a/server/webpush_store_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package server - -import ( - "fmt" - "github.com/stretchr/testify/require" - "net/netip" - "path/filepath" - "testing" - "time" -) - -func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - subs, err := webPush.SubscriptionsForTopic("test-topic") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) - require.Equal(t, subs[0].P256dh, "p256dh-key") - require.Equal(t, subs[0].Auth, "auth-key") - require.Equal(t, subs[0].UserID, "u_1234") - - subs2, err := webPush.SubscriptionsForTopic("mytopic") - require.Nil(t, err) - require.Len(t, subs2, 1) - require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) -} - -func TestWebPushStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - - // Insert 10 subscriptions with the same IP address - for i := 0; i < 10; i++ { - endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) - require.Nil(t, webPush.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - } - - // Another one for the same endpoint should be fine - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - // But with a different endpoint it should fail - require.Equal(t, errWebPushTooManySubscriptions, webPush.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - // But with a different IP address it should be fine again - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) -} - -func TestWebPushStore_UpsertSubscription_UpdateTopics(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - - // Insert subscription with two topics, and another with one topic - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) - - subs, err := webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 2) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) - - subs, err = webPush.SubscriptionsForTopic("topic2") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - - // Update the first subscription to have only one topic - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) - - subs, err = webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 2) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - - subs, err = webPush.SubscriptionsForTopic("topic2") - require.Nil(t, err) - require.Len(t, subs, 0) -} - -func TestWebPushStore_RemoveSubscriptionsByEndpoint(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - - // Insert subscription with two topics - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // And remove it again - require.Nil(t, webPush.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) - subs, err = webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) -} - -func TestWebPushStore_RemoveSubscriptionsByUserID(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - - // Insert subscription with two topics - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // And remove it again - require.Nil(t, webPush.RemoveSubscriptionsByUserID("u_1234")) - subs, err = webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) -} - -func TestWebPushStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - require.Equal(t, errWebPushUserIDCannotBeEmpty, webPush.RemoveSubscriptionsByUserID("")) -} - -func TestWebPushStore_MarkExpiryWarningSent(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - - // Insert subscription with two topics - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Mark them as warning sent - require.Nil(t, webPush.MarkExpiryWarningSent(subs)) - - rows, err := webPush.db.Query("SELECT endpoint FROM subscription WHERE warned_at > 0") - require.Nil(t, err) - defer rows.Close() - var endpoint string - require.True(t, rows.Next()) - require.Nil(t, rows.Scan(&endpoint)) - require.Nil(t, err) - require.Equal(t, testWebPushEndpoint, endpoint) - require.False(t, rows.Next()) -} - -func TestWebPushStore_SubscriptionsExpiring(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - - // Insert subscription with two topics - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Fake-mark them as soon-to-expire - _, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint) - require.Nil(t, err) - - // Should not be cleaned up yet - require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour)) - - // Run expiration - subs, err = webPush.SubscriptionsExpiring(7 * 24 * time.Hour) - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) -} - -func TestWebPushStore_RemoveExpiredSubscriptions(t *testing.T) { - webPush := newTestWebPushStore(t) - defer webPush.Close() - - // Insert subscription with two topics - require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Fake-mark them as expired - _, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint) - require.Nil(t, err) - - // Run expiration - require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour)) - - // List again, should be 0 - subs, err = webPush.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) -} - -func newTestWebPushStore(t *testing.T) *webPushStore { - webPush, err := newWebPushStore(filepath.Join(t.TempDir(), "webpush.db"), "") - require.Nil(t, err) - return webPush -} diff --git a/webpush/postgres.go b/webpush/postgres.go new file mode 100644 index 00000000..bc6a984e --- /dev/null +++ b/webpush/postgres.go @@ -0,0 +1,224 @@ +package webpush + +import ( + "database/sql" + "net/netip" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver + + "heckel.io/ntfy/v2/util" +) + +const ( + pgCreateTablesQuery = ` + CREATE TABLE IF NOT EXISTS webpush_subscription ( + id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + key_auth TEXT NOT NULL, + key_p256dh TEXT NOT NULL, + user_id TEXT NOT NULL, + subscriber_ip TEXT NOT NULL, + updated_at BIGINT NOT NULL, + warned_at BIGINT NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip); + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic); + CREATE TABLE IF NOT EXISTS webpush_schema_version ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + ` + + pgSelectSubscriptionIDByEndpoint = `SELECT id FROM webpush_subscription WHERE endpoint = $1` + pgSelectSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM webpush_subscription WHERE subscriber_ip = $1` + pgSelectSubscriptionsForTopicQuery = ` + SELECT s.id, s.endpoint, s.key_auth, s.key_p256dh, s.user_id + FROM webpush_subscription_topic st + JOIN webpush_subscription s ON s.id = st.subscription_id + WHERE st.topic = $1 + ORDER BY s.endpoint + ` + pgSelectSubscriptionsExpiringSoonQuery = ` + SELECT id, endpoint, key_auth, key_p256dh, user_id + FROM webpush_subscription + WHERE warned_at = 0 AND updated_at <= $1 + ` + pgInsertSubscriptionQuery = ` + INSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (endpoint) + DO UPDATE SET key_auth = EXCLUDED.key_auth, key_p256dh = EXCLUDED.key_p256dh, user_id = EXCLUDED.user_id, subscriber_ip = EXCLUDED.subscriber_ip, updated_at = EXCLUDED.updated_at, warned_at = EXCLUDED.warned_at + ` + pgUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2` + pgDeleteSubscriptionByEndpointQuery = `DELETE FROM webpush_subscription WHERE endpoint = $1` + pgDeleteSubscriptionByUserIDQuery = `DELETE FROM webpush_subscription WHERE user_id = $1` + pgDeleteSubscriptionByAgeQuery = `DELETE FROM webpush_subscription WHERE updated_at <= $1` + + pgInsertSubscriptionTopicQuery = `INSERT INTO webpush_subscription_topic (subscription_id, topic) VALUES ($1, $2)` + pgDeleteSubscriptionTopicAllQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id = $1` + pgDeleteSubscriptionTopicWithoutSubscription = `DELETE FROM webpush_subscription_topic WHERE subscription_id NOT IN (SELECT id FROM webpush_subscription)` +) + +// PostgreSQL schema management queries +const ( + pgCurrentSchemaVersion = 1 + pgInsertSchemaVersion = `INSERT INTO webpush_schema_version VALUES (1, $1)` + pgSelectSchemaVersionQuery = `SELECT version FROM webpush_schema_version WHERE id = 1` +) + +// PostgresStore is a web push subscription store backed by PostgreSQL. +type PostgresStore struct { + db *sql.DB +} + +// NewPostgresStore creates a new PostgreSQL-backed web push store. +func NewPostgresStore(dsn string) (*PostgresStore, error) { + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + return nil, err + } + if err := setupPostgresDB(db); err != nil { + return nil, err + } + return &PostgresStore{ + db: db, + }, nil +} + +// DB returns the underlying database connection. This is exported for testing purposes. +func (c *PostgresStore) DB() *sql.DB { + return c.db +} + +func setupPostgresDB(db *sql.DB) error { + // If 'wp_schema_version' table does not exist, this must be a new database + rows, err := db.Query(pgSelectSchemaVersionQuery) + if err != nil { + return setupNewPostgresDB(db) + } + return rows.Close() +} + +func setupNewPostgresDB(db *sql.DB) error { + if _, err := db.Exec(pgCreateTablesQuery); err != nil { + return err + } + if _, err := db.Exec(pgInsertSchemaVersion, pgCurrentSchemaVersion); err != nil { + return err + } + return nil +} + +// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. +func (c *PostgresStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // Read number of subscriptions for subscriber IP address + var subscriptionCount int + if err := tx.QueryRow(pgSelectSubscriptionCountBySubscriberIP, subscriberIP.String()).Scan(&subscriptionCount); err != nil { + return err + } + // Read existing subscription ID for endpoint (or create new ID) + var subscriptionID string + err = tx.QueryRow(pgSelectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID) + if err == sql.ErrNoRows { + if subscriptionCount >= SubscriptionEndpointLimitPerSubscriberIP { + return ErrWebPushTooManySubscriptions + } + subscriptionID = util.RandomStringPrefix(SubscriptionIDPrefix, SubscriptionIDLength) + } else if err != nil { + return err + } + // Insert or update subscription + updatedAt, warnedAt := time.Now().Unix(), 0 + if _, err = tx.Exec(pgInsertSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { + return err + } + // Replace all subscription topics + if _, err := tx.Exec(pgDeleteSubscriptionTopicAllQuery, subscriptionID); err != nil { + return err + } + for _, topic := range topics { + if _, err = tx.Exec(pgInsertSubscriptionTopicQuery, subscriptionID, topic); err != nil { + return err + } + } + return tx.Commit() +} + +// SubscriptionsForTopic returns all subscriptions for the given topic. +func (c *PostgresStore) SubscriptionsForTopic(topic string) ([]*Subscription, error) { + rows, err := c.db.Query(pgSelectSubscriptionsForTopicQuery, topic) + if err != nil { + return nil, err + } + defer rows.Close() + return subscriptionsFromRows(rows) +} + +// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period. +func (c *PostgresStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) { + rows, err := c.db.Query(pgSelectSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + return subscriptionsFromRows(rows) +} + +// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon. +func (c *PostgresStore) MarkExpiryWarningSent(subscriptions []*Subscription) error { + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, subscription := range subscriptions { + if _, err := tx.Exec(pgUpdateSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil { + return err + } + } + return tx.Commit() +} + +// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint. +func (c *PostgresStore) RemoveSubscriptionsByEndpoint(endpoint string) error { + _, err := c.db.Exec(pgDeleteSubscriptionByEndpointQuery, endpoint) + return err +} + +// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID. +func (c *PostgresStore) RemoveSubscriptionsByUserID(userID string) error { + if userID == "" { + return ErrWebPushUserIDCannotBeEmpty + } + _, err := c.db.Exec(pgDeleteSubscriptionByUserIDQuery, userID) + return err +} + +// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period. +func (c *PostgresStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error { + _, err := c.db.Exec(pgDeleteSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix()) + if err != nil { + return err + } + _, err = c.db.Exec(pgDeleteSubscriptionTopicWithoutSubscription) + return err +} + +// Close closes the underlying database connection. +func (c *PostgresStore) Close() error { + return c.db.Close() +} diff --git a/webpush/postgres_test.go b/webpush/postgres_test.go new file mode 100644 index 00000000..2cb77bc8 --- /dev/null +++ b/webpush/postgres_test.go @@ -0,0 +1,207 @@ +package webpush_test + +import ( + "fmt" + "net/netip" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/webpush" +) + +func newTestPostgresStore(t *testing.T) *webpush.PostgresStore { + dsn := os.Getenv("NTFY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") + } + store, err := webpush.NewPostgresStore(dsn) + require.Nil(t, err) + t.Cleanup(func() { + // Clean up tables after each test + db := store.DB() + db.Exec("DELETE FROM webpush_subscription_topic") + db.Exec("DELETE FROM webpush_subscription") + store.Close() + }) + // Clean up tables before test + db := store.DB() + db.Exec("DELETE FROM webpush_subscription_topic") + db.Exec("DELETE FROM webpush_subscription") + return store +} + +func TestPostgresStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) { + store := newTestPostgresStore(t) + + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + subs, err := store.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) + require.Equal(t, subs[0].P256dh, "p256dh-key") + require.Equal(t, subs[0].Auth, "auth-key") + require.Equal(t, subs[0].UserID, "u_1234") + + subs2, err := store.SubscriptionsForTopic("mytopic") + require.Nil(t, err) + require.Len(t, subs2, 1) + require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) +} + +func TestPostgresStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) { + store := newTestPostgresStore(t) + + // Insert 10 subscriptions with the same IP address + for i := 0; i < 10; i++ { + endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) + require.Nil(t, store.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + } + + // Another one for the same endpoint should be fine + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + // But with a different endpoint it should fail + require.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + // But with a different IP address it should be fine again + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) +} + +func TestPostgresStore_UpsertSubscription_UpdateTopics(t *testing.T) { + store := newTestPostgresStore(t) + + // Insert subscription with two topics, and another with one topic + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) + + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) + + subs, err = store.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + // Update the first subscription to have only one topic + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + subs, err = store.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestPostgresStore_RemoveSubscriptionsByEndpoint(t *testing.T) { + store := newTestPostgresStore(t) + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestPostgresStore_RemoveSubscriptionsByUserID(t *testing.T) { + store := newTestPostgresStore(t) + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestPostgresStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) { + store := newTestPostgresStore(t) + require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) +} + +func TestPostgresStore_MarkExpiryWarningSent(t *testing.T) { + store := newTestPostgresStore(t) + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Mark them as warning sent + require.Nil(t, store.MarkExpiryWarningSent(subs)) + + rows, err := store.DB().Query("SELECT endpoint FROM webpush_subscription WHERE warned_at > 0") + require.Nil(t, err) + defer rows.Close() + var endpoint string + require.True(t, rows.Next()) + require.Nil(t, rows.Scan(&endpoint)) + require.Nil(t, err) + require.Equal(t, testWebPushEndpoint, endpoint) + require.False(t, rows.Next()) +} + +func TestPostgresStore_SubscriptionsExpiring(t *testing.T) { + store := newTestPostgresStore(t) + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as soon-to-expire + _, err = store.DB().Exec("UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint) + require.Nil(t, err) + + // Should not be cleaned up yet + require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // Run expiration + subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) +} + +func TestPostgresStore_RemoveExpiredSubscriptions(t *testing.T) { + store := newTestPostgresStore(t) + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as expired + _, err = store.DB().Exec("UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint) + require.Nil(t, err) + + // Run expiration + require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // List again, should be 0 + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} diff --git a/webpush/sqlite.go b/webpush/sqlite.go new file mode 100644 index 00000000..a7871f97 --- /dev/null +++ b/webpush/sqlite.go @@ -0,0 +1,280 @@ +package webpush + +import ( + "database/sql" + "net/netip" + "time" + + _ "github.com/mattn/go-sqlite3" // SQLite driver + + "heckel.io/ntfy/v2/util" +) + +const ( + sqliteCreateWebPushSubscriptionsTableQuery = ` + BEGIN; + CREATE TABLE IF NOT EXISTS subscription ( + id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL, + key_auth TEXT NOT NULL, + key_p256dh TEXT NOT NULL, + user_id TEXT NOT NULL, + subscriber_ip TEXT NOT NULL, + updated_at INT NOT NULL, + warned_at INT NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint); + CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip); + CREATE TABLE IF NOT EXISTS subscription_topic ( + subscription_id TEXT NOT NULL, + topic TEXT NOT NULL, + PRIMARY KEY (subscription_id, topic), + FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + COMMIT; + ` + sqliteBuiltinStartupQueries = ` + PRAGMA foreign_keys = ON; + ` + + sqliteSelectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?` + sqliteSelectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?` + sqliteSelectWebPushSubscriptionsForTopicQuery = ` + SELECT id, endpoint, key_auth, key_p256dh, user_id + FROM subscription_topic st + JOIN subscription s ON s.id = st.subscription_id + WHERE st.topic = ? + ORDER BY endpoint + ` + sqliteSelectWebPushSubscriptionsExpiringSoonQuery = ` + SELECT id, endpoint, key_auth, key_p256dh, user_id + FROM subscription + WHERE warned_at = 0 AND updated_at <= ? + ` + sqliteInsertWebPushSubscriptionQuery = ` + INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (endpoint) + DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at + ` + sqliteUpdateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?` + sqliteDeleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?` + sqliteDeleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?` + sqliteDeleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan! + + sqliteInsertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)` + sqliteDeleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?` + sqliteDeleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)` +) + +// SQLite schema management queries +const ( + sqliteCurrentWebPushSchemaVersion = 1 + sqliteInsertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + sqliteSelectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` +) + +// SQLiteStore is a web push subscription store backed by SQLite. +type SQLiteStore struct { + db *sql.DB +} + +// NewSQLiteStore creates a new SQLite-backed web push store. +func NewSQLiteStore(filename, startupQueries string) (*SQLiteStore, error) { + db, err := sql.Open("sqlite3", filename) + if err != nil { + return nil, err + } + if err := setupSQLiteWebPushDB(db); err != nil { + return nil, err + } + if err := runSQLiteWebPushStartupQueries(db, startupQueries); err != nil { + return nil, err + } + return &SQLiteStore{ + db: db, + }, nil +} + +// DB returns the underlying database connection. This is exported for testing purposes. +func (c *SQLiteStore) DB() *sql.DB { + return c.db +} + +func setupSQLiteWebPushDB(db *sql.DB) error { + // If 'schemaVersion' table does not exist, this must be a new database + rows, err := db.Query(sqliteSelectWebPushSchemaVersionQuery) + if err != nil { + return setupNewSQLiteWebPushDB(db) + } + return rows.Close() +} + +func setupNewSQLiteWebPushDB(db *sql.DB) error { + if _, err := db.Exec(sqliteCreateWebPushSubscriptionsTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteInsertWebPushSchemaVersion, sqliteCurrentWebPushSchemaVersion); err != nil { + return err + } + return nil +} + +func runSQLiteWebPushStartupQueries(db *sql.DB, startupQueries string) error { + if _, err := db.Exec(startupQueries); err != nil { + return err + } + if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil { + return err + } + return nil +} + +// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all +// existing entries for a given endpoint. +func (c *SQLiteStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // Read number of subscriptions for subscriber IP address + rowsCount, err := tx.Query(sqliteSelectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String()) + if err != nil { + return err + } + defer rowsCount.Close() + var subscriptionCount int + if !rowsCount.Next() { + return ErrWebPushNoRows + } + if err := rowsCount.Scan(&subscriptionCount); err != nil { + return err + } + if err := rowsCount.Close(); err != nil { + return err + } + // Read existing subscription ID for endpoint (or create new ID) + rows, err := tx.Query(sqliteSelectWebPushSubscriptionIDByEndpoint, endpoint) + if err != nil { + return err + } + defer rows.Close() + var subscriptionID string + if rows.Next() { + if err := rows.Scan(&subscriptionID); err != nil { + return err + } + } else { + if subscriptionCount >= SubscriptionEndpointLimitPerSubscriberIP { + return ErrWebPushTooManySubscriptions + } + subscriptionID = util.RandomStringPrefix(SubscriptionIDPrefix, SubscriptionIDLength) + } + if err := rows.Close(); err != nil { + return err + } + // Insert or update subscription + updatedAt, warnedAt := time.Now().Unix(), 0 + if _, err = tx.Exec(sqliteInsertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { + return err + } + // Replace all subscription topics + if _, err := tx.Exec(sqliteDeleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil { + return err + } + for _, topic := range topics { + if _, err = tx.Exec(sqliteInsertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil { + return err + } + } + return tx.Commit() +} + +// SubscriptionsForTopic returns all subscriptions for the given topic. +func (c *SQLiteStore) SubscriptionsForTopic(topic string) ([]*Subscription, error) { + rows, err := c.db.Query(sqliteSelectWebPushSubscriptionsForTopicQuery, topic) + if err != nil { + return nil, err + } + defer rows.Close() + return subscriptionsFromRows(rows) +} + +// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period. +func (c *SQLiteStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) { + rows, err := c.db.Query(sqliteSelectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + return subscriptionsFromRows(rows) +} + +// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon. +func (c *SQLiteStore) MarkExpiryWarningSent(subscriptions []*Subscription) error { + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, subscription := range subscriptions { + if _, err := tx.Exec(sqliteUpdateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil { + return err + } + } + return tx.Commit() +} + +// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint. +func (c *SQLiteStore) RemoveSubscriptionsByEndpoint(endpoint string) error { + _, err := c.db.Exec(sqliteDeleteWebPushSubscriptionByEndpointQuery, endpoint) + return err +} + +// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID. +func (c *SQLiteStore) RemoveSubscriptionsByUserID(userID string) error { + if userID == "" { + return ErrWebPushUserIDCannotBeEmpty + } + _, err := c.db.Exec(sqliteDeleteWebPushSubscriptionByUserIDQuery, userID) + return err +} + +// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period. +func (c *SQLiteStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error { + _, err := c.db.Exec(sqliteDeleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix()) + if err != nil { + return err + } + _, err = c.db.Exec(sqliteDeleteWebPushSubscriptionTopicWithoutSubscription) + return err +} + +// Close closes the underlying database connection. +func (c *SQLiteStore) Close() error { + return c.db.Close() +} + +func subscriptionsFromRows(rows *sql.Rows) ([]*Subscription, error) { + subscriptions := make([]*Subscription, 0) + for rows.Next() { + var id, endpoint, auth, p256dh, userID string + if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil { + return nil, err + } + subscriptions = append(subscriptions, &Subscription{ + ID: id, + Endpoint: endpoint, + Auth: auth, + P256dh: p256dh, + UserID: userID, + }) + } + return subscriptions, nil +} diff --git a/webpush/sqlite_test.go b/webpush/sqlite_test.go new file mode 100644 index 00000000..c43eca86 --- /dev/null +++ b/webpush/sqlite_test.go @@ -0,0 +1,203 @@ +package webpush_test + +import ( + "fmt" + "net/netip" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/webpush" +) + +const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" + +func TestSQLiteStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + subs, err := store.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) + require.Equal(t, subs[0].P256dh, "p256dh-key") + require.Equal(t, subs[0].Auth, "auth-key") + require.Equal(t, subs[0].UserID, "u_1234") + + subs2, err := store.SubscriptionsForTopic("mytopic") + require.Nil(t, err) + require.Len(t, subs2, 1) + require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) +} + +func TestSQLiteStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + + // Insert 10 subscriptions with the same IP address + for i := 0; i < 10; i++ { + endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) + require.Nil(t, store.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + } + + // Another one for the same endpoint should be fine + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + // But with a different endpoint it should fail + require.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + // But with a different IP address it should be fine again + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) +} + +func TestSQLiteStore_UpsertSubscription_UpdateTopics(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + + // Insert subscription with two topics, and another with one topic + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) + + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) + + subs, err = store.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + // Update the first subscription to have only one topic + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + subs, err = store.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestSQLiteStore_RemoveSubscriptionsByEndpoint(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestSQLiteStore_RemoveSubscriptionsByUserID(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func TestSQLiteStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) +} + +func TestSQLiteStore_MarkExpiryWarningSent(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Mark them as warning sent + require.Nil(t, store.MarkExpiryWarningSent(subs)) + + rows, err := store.DB().Query("SELECT endpoint FROM subscription WHERE warned_at > 0") + require.Nil(t, err) + defer rows.Close() + var endpoint string + require.True(t, rows.Next()) + require.Nil(t, rows.Scan(&endpoint)) + require.Nil(t, err) + require.Equal(t, testWebPushEndpoint, endpoint) + require.False(t, rows.Next()) +} + +func TestSQLiteStore_SubscriptionsExpiring(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as soon-to-expire + _, err = store.DB().Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint) + require.Nil(t, err) + + // Should not be cleaned up yet + require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // Run expiration + subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) +} + +func TestSQLiteStore_RemoveExpiredSubscriptions(t *testing.T) { + store := newTestSQLiteStore(t) + defer store.Close() + + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as expired + _, err = store.DB().Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint) + require.Nil(t, err) + + // Run expiration + require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // List again, should be 0 + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func newTestSQLiteStore(t *testing.T) *webpush.SQLiteStore { + store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") + require.Nil(t, err) + return store +} diff --git a/webpush/store.go b/webpush/store.go new file mode 100644 index 00000000..a711b6bb --- /dev/null +++ b/webpush/store.go @@ -0,0 +1,51 @@ +package webpush + +import ( + "errors" + "net/netip" + "time" + + "heckel.io/ntfy/v2/log" +) + +const ( + SubscriptionIDPrefix = "wps_" + SubscriptionIDLength = 10 + SubscriptionEndpointLimitPerSubscriberIP = 10 +) + +var ( + ErrWebPushNoRows = errors.New("no rows found") + ErrWebPushTooManySubscriptions = errors.New("too many subscriptions") + ErrWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty") +) + +// Store is the interface for a web push subscription store. +type Store interface { + UpsertSubscription(endpoint, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error + SubscriptionsForTopic(topic string) ([]*Subscription, error) + SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) + MarkExpiryWarningSent(subscriptions []*Subscription) error + RemoveSubscriptionsByEndpoint(endpoint string) error + RemoveSubscriptionsByUserID(userID string) error + RemoveExpiredSubscriptions(expireAfter time.Duration) error + Close() error +} + +// Subscription represents a web push subscription. +type Subscription struct { + ID string + Endpoint string + Auth string + P256dh string + UserID string +} + +// Context returns the logging context for the subscription. +func (w *Subscription) Context() log.Context { + return map[string]any{ + "web_push_subscription_id": w.ID, + "web_push_subscription_user_id": w.UserID, + "web_push_subscription_endpoint": w.Endpoint, + } +} From 533143766434ed448a8688c709933018a255a4a6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 12:38:00 -0500 Subject: [PATCH 182/548] Unify webpush store tests across SQLite and PostgreSQL backends Share test logic in store_test.go with thin per-backend wrappers. Add SetSubscriptionUpdatedAt to both stores, removing the need for raw SQL and the DB() accessor in tests. --- server/server_webpush_test.go | 9 +- webpush/postgres.go | 12 ++- webpush/postgres_test.go | 186 ++++++---------------------------- webpush/sqlite.go | 12 ++- webpush/sqlite_test.go | 178 +++----------------------------- webpush/store_test.go | 169 ++++++++++++++++++++++++++++++ 6 files changed, 233 insertions(+), 333 deletions(-) create mode 100644 webpush/store_test.go diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go index fe77787e..03101578 100644 --- a/server/server_webpush_test.go +++ b/server/server_webpush_test.go @@ -237,11 +237,11 @@ func TestServer_WebPush_Expiry(t *testing.T) { })) defer pushService.Close() - addSubscription(t, s, pushService.URL+"/push-receive", "test-topic") + endpoint := pushService.URL + "/push-receive" + addSubscription(t, s, endpoint, "test-topic") requireSubscriptionCount(t, s, "test-topic", 1) - _, err := s.webPush.(*wpush.SQLiteStore).DB().Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix()) - require.Nil(t, err) + require.Nil(t, s.webPush.(*wpush.SQLiteStore).SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix())) s.pruneAndNotifyWebPushSubscriptions() requireSubscriptionCount(t, s, "test-topic", 1) @@ -250,8 +250,7 @@ func TestServer_WebPush_Expiry(t *testing.T) { return received.Load() }) - _, err = s.webPush.(*wpush.SQLiteStore).DB().Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix()) - require.Nil(t, err) + require.Nil(t, s.webPush.(*wpush.SQLiteStore).SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix())) s.pruneAndNotifyWebPushSubscriptions() waitFor(t, func() bool { diff --git a/webpush/postgres.go b/webpush/postgres.go index bc6a984e..6d4a1027 100644 --- a/webpush/postgres.go +++ b/webpush/postgres.go @@ -94,11 +94,6 @@ func NewPostgresStore(dsn string) (*PostgresStore, error) { }, nil } -// DB returns the underlying database connection. This is exported for testing purposes. -func (c *PostgresStore) DB() *sql.DB { - return c.db -} - func setupPostgresDB(db *sql.DB) error { // If 'wp_schema_version' table does not exist, this must be a new database rows, err := db.Query(pgSelectSchemaVersionQuery) @@ -218,6 +213,13 @@ func (c *PostgresStore) RemoveExpiredSubscriptions(expireAfter time.Duration) er return err } +// SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is +// exported for testing purposes and is not part of the Store interface. +func (c *PostgresStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { + _, err := c.db.Exec("UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2", updatedAt, endpoint) + return err +} + // Close closes the underlying database connection. func (c *PostgresStore) Close() error { return c.db.Close() diff --git a/webpush/postgres_test.go b/webpush/postgres_test.go index 2cb77bc8..77334839 100644 --- a/webpush/postgres_test.go +++ b/webpush/postgres_test.go @@ -1,13 +1,14 @@ package webpush_test import ( + "database/sql" "fmt" - "net/netip" + "net/url" "os" "testing" - "time" "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/webpush" ) @@ -16,192 +17,67 @@ func newTestPostgresStore(t *testing.T) *webpush.PostgresStore { if dsn == "" { t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") } - store, err := webpush.NewPostgresStore(dsn) + // Create a unique schema for this test + schema := fmt.Sprintf("test_%s", util.RandomString(10)) + setupDB, err := sql.Open("pgx", dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + // Open store with search_path set to the new schema + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + store, err := webpush.NewPostgresStore(u.String()) require.Nil(t, err) t.Cleanup(func() { - // Clean up tables after each test - db := store.DB() - db.Exec("DELETE FROM webpush_subscription_topic") - db.Exec("DELETE FROM webpush_subscription") store.Close() + cleanDB, err := sql.Open("pgx", dsn) + if err == nil { + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + } }) - // Clean up tables before test - db := store.DB() - db.Exec("DELETE FROM webpush_subscription_topic") - db.Exec("DELETE FROM webpush_subscription") return store } func TestPostgresStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) { - store := newTestPostgresStore(t) - - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - subs, err := store.SubscriptionsForTopic("test-topic") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) - require.Equal(t, subs[0].P256dh, "p256dh-key") - require.Equal(t, subs[0].Auth, "auth-key") - require.Equal(t, subs[0].UserID, "u_1234") - - subs2, err := store.SubscriptionsForTopic("mytopic") - require.Nil(t, err) - require.Len(t, subs2, 1) - require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) + testStoreUpsertSubscription_SubscriptionsForTopic(t, newTestPostgresStore(t)) } func TestPostgresStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) { - store := newTestPostgresStore(t) - - // Insert 10 subscriptions with the same IP address - for i := 0; i < 10; i++ { - endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) - require.Nil(t, store.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - } - - // Another one for the same endpoint should be fine - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - // But with a different endpoint it should fail - require.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - // But with a different IP address it should be fine again - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) + testStoreUpsertSubscription_SubscriberIPLimitReached(t, newTestPostgresStore(t)) } func TestPostgresStore_UpsertSubscription_UpdateTopics(t *testing.T) { - store := newTestPostgresStore(t) - - // Insert subscription with two topics, and another with one topic - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) - - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 2) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) - - subs, err = store.SubscriptionsForTopic("topic2") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - - // Update the first subscription to have only one topic - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) - - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 2) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - - subs, err = store.SubscriptionsForTopic("topic2") - require.Nil(t, err) - require.Len(t, subs, 0) + testStoreUpsertSubscription_UpdateTopics(t, newTestPostgresStore(t)) } func TestPostgresStore_RemoveSubscriptionsByEndpoint(t *testing.T) { - store := newTestPostgresStore(t) - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // And remove it again - require.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) + testStoreRemoveSubscriptionsByEndpoint(t, newTestPostgresStore(t)) } func TestPostgresStore_RemoveSubscriptionsByUserID(t *testing.T) { - store := newTestPostgresStore(t) - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // And remove it again - require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) + testStoreRemoveSubscriptionsByUserID(t, newTestPostgresStore(t)) } func TestPostgresStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) { - store := newTestPostgresStore(t) - require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) + testStoreRemoveSubscriptionsByUserID_Empty(t, newTestPostgresStore(t)) } func TestPostgresStore_MarkExpiryWarningSent(t *testing.T) { store := newTestPostgresStore(t) - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Mark them as warning sent - require.Nil(t, store.MarkExpiryWarningSent(subs)) - - rows, err := store.DB().Query("SELECT endpoint FROM webpush_subscription WHERE warned_at > 0") - require.Nil(t, err) - defer rows.Close() - var endpoint string - require.True(t, rows.Next()) - require.Nil(t, rows.Scan(&endpoint)) - require.Nil(t, err) - require.Equal(t, testWebPushEndpoint, endpoint) - require.False(t, rows.Next()) + testStoreMarkExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt) } func TestPostgresStore_SubscriptionsExpiring(t *testing.T) { store := newTestPostgresStore(t) - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Fake-mark them as soon-to-expire - _, err = store.DB().Exec("UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint) - require.Nil(t, err) - - // Should not be cleaned up yet - require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) - - // Run expiration - subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) + testStoreSubscriptionsExpiring(t, store, store.SetSubscriptionUpdatedAt) } func TestPostgresStore_RemoveExpiredSubscriptions(t *testing.T) { store := newTestPostgresStore(t) - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Fake-mark them as expired - _, err = store.DB().Exec("UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint) - require.Nil(t, err) - - // Run expiration - require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) - - // List again, should be 0 - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) + testStoreRemoveExpiredSubscriptions(t, store, store.SetSubscriptionUpdatedAt) } diff --git a/webpush/sqlite.go b/webpush/sqlite.go index a7871f97..2483fa93 100644 --- a/webpush/sqlite.go +++ b/webpush/sqlite.go @@ -101,11 +101,6 @@ func NewSQLiteStore(filename, startupQueries string) (*SQLiteStore, error) { }, nil } -// DB returns the underlying database connection. This is exported for testing purposes. -func (c *SQLiteStore) DB() *sql.DB { - return c.db -} - func setupSQLiteWebPushDB(db *sql.DB) error { // If 'schemaVersion' table does not exist, this must be a new database rows, err := db.Query(sqliteSelectWebPushSchemaVersionQuery) @@ -256,6 +251,13 @@ func (c *SQLiteStore) RemoveExpiredSubscriptions(expireAfter time.Duration) erro return err } +// SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is +// exported for testing purposes and is not part of the Store interface. +func (c *SQLiteStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { + _, err := c.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", updatedAt, endpoint) + return err +} + // Close closes the underlying database connection. func (c *SQLiteStore) Close() error { return c.db.Close() diff --git a/webpush/sqlite_test.go b/webpush/sqlite_test.go index c43eca86..b64a93b9 100644 --- a/webpush/sqlite_test.go +++ b/webpush/sqlite_test.go @@ -1,203 +1,55 @@ package webpush_test import ( - "fmt" - "net/netip" "path/filepath" "testing" - "time" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/webpush" ) -const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" +func newTestSQLiteStore(t *testing.T) *webpush.SQLiteStore { + store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") + require.Nil(t, err) + t.Cleanup(func() { store.Close() }) + return store +} func TestSQLiteStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) { - store := newTestSQLiteStore(t) - defer store.Close() - - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - subs, err := store.SubscriptionsForTopic("test-topic") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) - require.Equal(t, subs[0].P256dh, "p256dh-key") - require.Equal(t, subs[0].Auth, "auth-key") - require.Equal(t, subs[0].UserID, "u_1234") - - subs2, err := store.SubscriptionsForTopic("mytopic") - require.Nil(t, err) - require.Len(t, subs2, 1) - require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) + testStoreUpsertSubscription_SubscriptionsForTopic(t, newTestSQLiteStore(t)) } func TestSQLiteStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) { - store := newTestSQLiteStore(t) - defer store.Close() - - // Insert 10 subscriptions with the same IP address - for i := 0; i < 10; i++ { - endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) - require.Nil(t, store.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - } - - // Another one for the same endpoint should be fine - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - // But with a different endpoint it should fail - require.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - // But with a different IP address it should be fine again - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) + testStoreUpsertSubscription_SubscriberIPLimitReached(t, newTestSQLiteStore(t)) } func TestSQLiteStore_UpsertSubscription_UpdateTopics(t *testing.T) { - store := newTestSQLiteStore(t) - defer store.Close() - - // Insert subscription with two topics, and another with one topic - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) - - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 2) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) - - subs, err = store.SubscriptionsForTopic("topic2") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - - // Update the first subscription to have only one topic - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) - - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 2) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - - subs, err = store.SubscriptionsForTopic("topic2") - require.Nil(t, err) - require.Len(t, subs, 0) + testStoreUpsertSubscription_UpdateTopics(t, newTestSQLiteStore(t)) } func TestSQLiteStore_RemoveSubscriptionsByEndpoint(t *testing.T) { - store := newTestSQLiteStore(t) - defer store.Close() - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // And remove it again - require.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) + testStoreRemoveSubscriptionsByEndpoint(t, newTestSQLiteStore(t)) } func TestSQLiteStore_RemoveSubscriptionsByUserID(t *testing.T) { - store := newTestSQLiteStore(t) - defer store.Close() - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // And remove it again - require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) + testStoreRemoveSubscriptionsByUserID(t, newTestSQLiteStore(t)) } func TestSQLiteStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) { - store := newTestSQLiteStore(t) - defer store.Close() - require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) + testStoreRemoveSubscriptionsByUserID_Empty(t, newTestSQLiteStore(t)) } func TestSQLiteStore_MarkExpiryWarningSent(t *testing.T) { store := newTestSQLiteStore(t) - defer store.Close() - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Mark them as warning sent - require.Nil(t, store.MarkExpiryWarningSent(subs)) - - rows, err := store.DB().Query("SELECT endpoint FROM subscription WHERE warned_at > 0") - require.Nil(t, err) - defer rows.Close() - var endpoint string - require.True(t, rows.Next()) - require.Nil(t, rows.Scan(&endpoint)) - require.Nil(t, err) - require.Equal(t, testWebPushEndpoint, endpoint) - require.False(t, rows.Next()) + testStoreMarkExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt) } func TestSQLiteStore_SubscriptionsExpiring(t *testing.T) { store := newTestSQLiteStore(t) - defer store.Close() - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Fake-mark them as soon-to-expire - _, err = store.DB().Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint) - require.Nil(t, err) - - // Should not be cleaned up yet - require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) - - // Run expiration - subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) + testStoreSubscriptionsExpiring(t, store, store.SetSubscriptionUpdatedAt) } func TestSQLiteStore_RemoveExpiredSubscriptions(t *testing.T) { store := newTestSQLiteStore(t) - defer store.Close() - - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - - // Fake-mark them as expired - _, err = store.DB().Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint) - require.Nil(t, err) - - // Run expiration - require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) - - // List again, should be 0 - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) -} - -func newTestSQLiteStore(t *testing.T) *webpush.SQLiteStore { - store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") - require.Nil(t, err) - return store + testStoreRemoveExpiredSubscriptions(t, store, store.SetSubscriptionUpdatedAt) } diff --git a/webpush/store_test.go b/webpush/store_test.go new file mode 100644 index 00000000..9f1c7adc --- /dev/null +++ b/webpush/store_test.go @@ -0,0 +1,169 @@ +package webpush_test + +import ( + "fmt" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/webpush" +) + +const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" + +func testStoreUpsertSubscription_SubscriptionsForTopic(t *testing.T, store webpush.Store) { + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + subs, err := store.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) + require.Equal(t, subs[0].P256dh, "p256dh-key") + require.Equal(t, subs[0].Auth, "auth-key") + require.Equal(t, subs[0].UserID, "u_1234") + + subs2, err := store.SubscriptionsForTopic("mytopic") + require.Nil(t, err) + require.Len(t, subs2, 1) + require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) +} + +func testStoreUpsertSubscription_SubscriberIPLimitReached(t *testing.T, store webpush.Store) { + // Insert 10 subscriptions with the same IP address + for i := 0; i < 10; i++ { + endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) + require.Nil(t, store.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + } + + // Another one for the same endpoint should be fine + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + // But with a different endpoint it should fail + require.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + + // But with a different IP address it should be fine again + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) +} + +func testStoreUpsertSubscription_UpdateTopics(t *testing.T, store webpush.Store) { + // Insert subscription with two topics, and another with one topic + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) + + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) + + subs, err = store.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + // Update the first subscription to have only one topic + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + subs, err = store.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func testStoreRemoveSubscriptionsByEndpoint(t *testing.T, store webpush.Store) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func testStoreRemoveSubscriptionsByUserID(t *testing.T, store webpush.Store) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func testStoreRemoveSubscriptionsByUserID_Empty(t *testing.T, store webpush.Store) { + require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) +} + +func testStoreMarkExpiryWarningSent(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + + // Set updated_at to the past so it shows up as expiring + require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix())) + + // Verify subscription appears in expiring list (warned_at == 0) + subs, err := store.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) + + // Mark them as warning sent + require.Nil(t, store.MarkExpiryWarningSent(subs)) + + // Verify subscription no longer appears in expiring list (warned_at > 0) + subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 0) +} + +func testStoreSubscriptionsExpiring(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as soon-to-expire + require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix())) + + // Should not be cleaned up yet + require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // Run expiration + subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) +} + +func testStoreRemoveExpiredSubscriptions(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as expired + require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-10*24*time.Hour).Unix())) + + // Run expiration + require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // List again, should be 0 + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) +} From a8dcecdb6d2e862782711f537edff5122c330020 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 18:53:12 -0500 Subject: [PATCH 183/548] Refactor webpush store tests and add coverage - Add SetSubscriptionUpdatedAt to Store interface, remove DB() accessor - Rename store files to store_sqlite.go and store_postgres.go - Use camelCase for test function names - Add tests for upsert field updates and multi-user removal - Use transaction in setupNewPostgresDB - Use lowercase "excluded." in PostgreSQL upsert query --- server/server_webpush_test.go | 5 +- webpush/postgres_test.go | 83 -------------------- webpush/sqlite_test.go | 55 ------------- webpush/store.go | 1 + webpush/{postgres.go => store_postgres.go} | 15 ++-- webpush/store_postgres_test.go | 91 ++++++++++++++++++++++ webpush/{sqlite.go => store_sqlite.go} | 0 webpush/store_sqlite_test.go | 63 +++++++++++++++ webpush/store_test.go | 62 ++++++++++++--- 9 files changed, 220 insertions(+), 155 deletions(-) delete mode 100644 webpush/postgres_test.go delete mode 100644 webpush/sqlite_test.go rename webpush/{postgres.go => store_postgres.go} (93%) create mode 100644 webpush/store_postgres_test.go rename webpush/{sqlite.go => store_sqlite.go} (100%) create mode 100644 webpush/store_sqlite_test.go diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go index 03101578..7bea9460 100644 --- a/server/server_webpush_test.go +++ b/server/server_webpush_test.go @@ -19,7 +19,6 @@ import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" - wpush "heckel.io/ntfy/v2/webpush" ) const ( @@ -241,7 +240,7 @@ func TestServer_WebPush_Expiry(t *testing.T) { addSubscription(t, s, endpoint, "test-topic") requireSubscriptionCount(t, s, "test-topic", 1) - require.Nil(t, s.webPush.(*wpush.SQLiteStore).SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix())) + require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix())) s.pruneAndNotifyWebPushSubscriptions() requireSubscriptionCount(t, s, "test-topic", 1) @@ -250,7 +249,7 @@ func TestServer_WebPush_Expiry(t *testing.T) { return received.Load() }) - require.Nil(t, s.webPush.(*wpush.SQLiteStore).SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix())) + require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix())) s.pruneAndNotifyWebPushSubscriptions() waitFor(t, func() bool { diff --git a/webpush/postgres_test.go b/webpush/postgres_test.go deleted file mode 100644 index 77334839..00000000 --- a/webpush/postgres_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package webpush_test - -import ( - "database/sql" - "fmt" - "net/url" - "os" - "testing" - - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/util" - "heckel.io/ntfy/v2/webpush" -) - -func newTestPostgresStore(t *testing.T) *webpush.PostgresStore { - dsn := os.Getenv("NTFY_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") - } - // Create a unique schema for this test - schema := fmt.Sprintf("test_%s", util.RandomString(10)) - setupDB, err := sql.Open("pgx", dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - // Open store with search_path set to the new schema - u, err := url.Parse(dsn) - require.Nil(t, err) - q := u.Query() - q.Set("search_path", schema) - u.RawQuery = q.Encode() - store, err := webpush.NewPostgresStore(u.String()) - require.Nil(t, err) - t.Cleanup(func() { - store.Close() - cleanDB, err := sql.Open("pgx", dsn) - if err == nil { - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() - } - }) - return store -} - -func TestPostgresStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) { - testStoreUpsertSubscription_SubscriptionsForTopic(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) { - testStoreUpsertSubscription_SubscriberIPLimitReached(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_UpsertSubscription_UpdateTopics(t *testing.T) { - testStoreUpsertSubscription_UpdateTopics(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_RemoveSubscriptionsByEndpoint(t *testing.T) { - testStoreRemoveSubscriptionsByEndpoint(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_RemoveSubscriptionsByUserID(t *testing.T) { - testStoreRemoveSubscriptionsByUserID(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) { - testStoreRemoveSubscriptionsByUserID_Empty(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MarkExpiryWarningSent(t *testing.T) { - store := newTestPostgresStore(t) - testStoreMarkExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt) -} - -func TestPostgresStore_SubscriptionsExpiring(t *testing.T) { - store := newTestPostgresStore(t) - testStoreSubscriptionsExpiring(t, store, store.SetSubscriptionUpdatedAt) -} - -func TestPostgresStore_RemoveExpiredSubscriptions(t *testing.T) { - store := newTestPostgresStore(t) - testStoreRemoveExpiredSubscriptions(t, store, store.SetSubscriptionUpdatedAt) -} diff --git a/webpush/sqlite_test.go b/webpush/sqlite_test.go deleted file mode 100644 index b64a93b9..00000000 --- a/webpush/sqlite_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package webpush_test - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/webpush" -) - -func newTestSQLiteStore(t *testing.T) *webpush.SQLiteStore { - store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") - require.Nil(t, err) - t.Cleanup(func() { store.Close() }) - return store -} - -func TestSQLiteStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) { - testStoreUpsertSubscription_SubscriptionsForTopic(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) { - testStoreUpsertSubscription_SubscriberIPLimitReached(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStore_UpsertSubscription_UpdateTopics(t *testing.T) { - testStoreUpsertSubscription_UpdateTopics(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStore_RemoveSubscriptionsByEndpoint(t *testing.T) { - testStoreRemoveSubscriptionsByEndpoint(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStore_RemoveSubscriptionsByUserID(t *testing.T) { - testStoreRemoveSubscriptionsByUserID(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) { - testStoreRemoveSubscriptionsByUserID_Empty(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStore_MarkExpiryWarningSent(t *testing.T) { - store := newTestSQLiteStore(t) - testStoreMarkExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt) -} - -func TestSQLiteStore_SubscriptionsExpiring(t *testing.T) { - store := newTestSQLiteStore(t) - testStoreSubscriptionsExpiring(t, store, store.SetSubscriptionUpdatedAt) -} - -func TestSQLiteStore_RemoveExpiredSubscriptions(t *testing.T) { - store := newTestSQLiteStore(t) - testStoreRemoveExpiredSubscriptions(t, store, store.SetSubscriptionUpdatedAt) -} diff --git a/webpush/store.go b/webpush/store.go index a711b6bb..5dd94c93 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -29,6 +29,7 @@ type Store interface { RemoveSubscriptionsByEndpoint(endpoint string) error RemoveSubscriptionsByUserID(userID string) error RemoveExpiredSubscriptions(expireAfter time.Duration) error + SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error Close() error } diff --git a/webpush/postgres.go b/webpush/store_postgres.go similarity index 93% rename from webpush/postgres.go rename to webpush/store_postgres.go index 6d4a1027..1889f92f 100644 --- a/webpush/postgres.go +++ b/webpush/store_postgres.go @@ -53,7 +53,7 @@ const ( INSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (endpoint) - DO UPDATE SET key_auth = EXCLUDED.key_auth, key_p256dh = EXCLUDED.key_p256dh, user_id = EXCLUDED.user_id, subscriber_ip = EXCLUDED.subscriber_ip, updated_at = EXCLUDED.updated_at, warned_at = EXCLUDED.warned_at + DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at ` pgUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2` pgDeleteSubscriptionByEndpointQuery = `DELETE FROM webpush_subscription WHERE endpoint = $1` @@ -95,7 +95,7 @@ func NewPostgresStore(dsn string) (*PostgresStore, error) { } func setupPostgresDB(db *sql.DB) error { - // If 'wp_schema_version' table does not exist, this must be a new database + // If 'webpush_schema_version' table does not exist, this must be a new database rows, err := db.Query(pgSelectSchemaVersionQuery) if err != nil { return setupNewPostgresDB(db) @@ -104,13 +104,18 @@ func setupPostgresDB(db *sql.DB) error { } func setupNewPostgresDB(db *sql.DB) error { - if _, err := db.Exec(pgCreateTablesQuery); err != nil { + tx, err := db.Begin() + if err != nil { return err } - if _, err := db.Exec(pgInsertSchemaVersion, pgCurrentSchemaVersion); err != nil { + defer tx.Rollback() + if _, err := tx.Exec(pgCreateTablesQuery); err != nil { return err } - return nil + if _, err := tx.Exec(pgInsertSchemaVersion, pgCurrentSchemaVersion); err != nil { + return err + } + return tx.Commit() } // UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. diff --git a/webpush/store_postgres_test.go b/webpush/store_postgres_test.go new file mode 100644 index 00000000..07a3c6be --- /dev/null +++ b/webpush/store_postgres_test.go @@ -0,0 +1,91 @@ +package webpush_test + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/util" + "heckel.io/ntfy/v2/webpush" +) + +func newTestPostgresStore(t *testing.T) *webpush.PostgresStore { + dsn := os.Getenv("NTFY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") + } + // Create a unique schema for this test + schema := fmt.Sprintf("test_%s", util.RandomString(10)) + setupDB, err := sql.Open("pgx", dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + // Open store with search_path set to the new schema + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + store, err := webpush.NewPostgresStore(u.String()) + require.Nil(t, err) + t.Cleanup(func() { + store.Close() + cleanDB, err := sql.Open("pgx", dsn) + if err == nil { + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + } + }) + return store +} + +func TestPostgresStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) { + testStoreUpsertSubscriptionSubscriptionsForTopic(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) { + testStoreUpsertSubscriptionSubscriberIPLimitReached(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUpsertSubscriptionUpdateTopics(t *testing.T) { + testStoreUpsertSubscriptionUpdateTopics(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUpsertSubscriptionUpdateFields(t *testing.T) { + testStoreUpsertSubscriptionUpdateFields(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreRemoveByUserIDMultiple(t *testing.T) { + testStoreRemoveByUserIDMultiple(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreRemoveByEndpoint(t *testing.T) { + testStoreRemoveByEndpoint(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreRemoveByUserID(t *testing.T) { + testStoreRemoveByUserID(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreRemoveByUserIDEmpty(t *testing.T) { + testStoreRemoveByUserIDEmpty(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreExpiryWarningSent(t *testing.T) { + store := newTestPostgresStore(t) + testStoreExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt) +} + +func TestPostgresStoreExpiring(t *testing.T) { + store := newTestPostgresStore(t) + testStoreExpiring(t, store, store.SetSubscriptionUpdatedAt) +} + +func TestPostgresStoreRemoveExpired(t *testing.T) { + store := newTestPostgresStore(t) + testStoreRemoveExpired(t, store, store.SetSubscriptionUpdatedAt) +} diff --git a/webpush/sqlite.go b/webpush/store_sqlite.go similarity index 100% rename from webpush/sqlite.go rename to webpush/store_sqlite.go diff --git a/webpush/store_sqlite_test.go b/webpush/store_sqlite_test.go new file mode 100644 index 00000000..b01ca55d --- /dev/null +++ b/webpush/store_sqlite_test.go @@ -0,0 +1,63 @@ +package webpush_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/webpush" +) + +func newTestSQLiteStore(t *testing.T) *webpush.SQLiteStore { + store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") + require.Nil(t, err) + t.Cleanup(func() { store.Close() }) + return store +} + +func TestSQLiteStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) { + testStoreUpsertSubscriptionSubscriptionsForTopic(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) { + testStoreUpsertSubscriptionSubscriberIPLimitReached(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUpsertSubscriptionUpdateTopics(t *testing.T) { + testStoreUpsertSubscriptionUpdateTopics(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUpsertSubscriptionUpdateFields(t *testing.T) { + testStoreUpsertSubscriptionUpdateFields(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreRemoveByUserIDMultiple(t *testing.T) { + testStoreRemoveByUserIDMultiple(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreRemoveByEndpoint(t *testing.T) { + testStoreRemoveByEndpoint(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreRemoveByUserID(t *testing.T) { + testStoreRemoveByUserID(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreRemoveByUserIDEmpty(t *testing.T) { + testStoreRemoveByUserIDEmpty(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreExpiryWarningSent(t *testing.T) { + store := newTestSQLiteStore(t) + testStoreExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt) +} + +func TestSQLiteStoreExpiring(t *testing.T) { + store := newTestSQLiteStore(t) + testStoreExpiring(t, store, store.SetSubscriptionUpdatedAt) +} + +func TestSQLiteStoreRemoveExpired(t *testing.T) { + store := newTestSQLiteStore(t) + testStoreRemoveExpired(t, store, store.SetSubscriptionUpdatedAt) +} diff --git a/webpush/store_test.go b/webpush/store_test.go index 9f1c7adc..fe2e28f3 100644 --- a/webpush/store_test.go +++ b/webpush/store_test.go @@ -12,7 +12,7 @@ import ( const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" -func testStoreUpsertSubscription_SubscriptionsForTopic(t *testing.T, store webpush.Store) { +func testStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T, store webpush.Store) { require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) subs, err := store.SubscriptionsForTopic("test-topic") @@ -29,7 +29,7 @@ func testStoreUpsertSubscription_SubscriptionsForTopic(t *testing.T, store webpu require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) } -func testStoreUpsertSubscription_SubscriberIPLimitReached(t *testing.T, store webpush.Store) { +func testStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T, store webpush.Store) { // Insert 10 subscriptions with the same IP address for i := 0; i < 10; i++ { endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) @@ -46,7 +46,7 @@ func testStoreUpsertSubscription_SubscriberIPLimitReached(t *testing.T, store we require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) } -func testStoreUpsertSubscription_UpdateTopics(t *testing.T, store webpush.Store) { +func testStoreUpsertSubscriptionUpdateTopics(t *testing.T, store webpush.Store) { // Insert subscription with two topics, and another with one topic require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) @@ -75,7 +75,51 @@ func testStoreUpsertSubscription_UpdateTopics(t *testing.T, store webpush.Store) require.Len(t, subs, 0) } -func testStoreRemoveSubscriptionsByEndpoint(t *testing.T, store webpush.Store) { +func testStoreUpsertSubscriptionUpdateFields(t *testing.T, store webpush.Store) { + // Insert a subscription + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, "auth-key", subs[0].Auth) + require.Equal(t, "p256dh-key", subs[0].P256dh) + require.Equal(t, "u_1234", subs[0].UserID) + + // Re-upsert the same endpoint with different auth, p256dh, and userID + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "new-auth", "new-p256dh", "u_5678", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) + require.Equal(t, "new-auth", subs[0].Auth) + require.Equal(t, "new-p256dh", subs[0].P256dh) + require.Equal(t, "u_5678", subs[0].UserID) +} + +func testStoreRemoveByUserIDMultiple(t *testing.T, store webpush.Store) { + // Insert two subscriptions for u_1234 and one for u_5678 + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"2", "auth-key", "p256dh-key", "u_5678", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) + + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 3) + + // Remove all subscriptions for u_1234 + require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) + + // Only u_5678's subscription should remain + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint+"2", subs[0].Endpoint) + require.Equal(t, "u_5678", subs[0].UserID) +} + +func testStoreRemoveByEndpoint(t *testing.T, store webpush.Store) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) subs, err := store.SubscriptionsForTopic("topic1") @@ -89,7 +133,7 @@ func testStoreRemoveSubscriptionsByEndpoint(t *testing.T, store webpush.Store) { require.Len(t, subs, 0) } -func testStoreRemoveSubscriptionsByUserID(t *testing.T, store webpush.Store) { +func testStoreRemoveByUserID(t *testing.T, store webpush.Store) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) subs, err := store.SubscriptionsForTopic("topic1") @@ -103,11 +147,11 @@ func testStoreRemoveSubscriptionsByUserID(t *testing.T, store webpush.Store) { require.Len(t, subs, 0) } -func testStoreRemoveSubscriptionsByUserID_Empty(t *testing.T, store webpush.Store) { +func testStoreRemoveByUserIDEmpty(t *testing.T, store webpush.Store) { require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) } -func testStoreMarkExpiryWarningSent(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { +func testStoreExpiryWarningSent(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) @@ -129,7 +173,7 @@ func testStoreMarkExpiryWarningSent(t *testing.T, store webpush.Store, setUpdate require.Len(t, subs, 0) } -func testStoreSubscriptionsExpiring(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { +func testStoreExpiring(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) subs, err := store.SubscriptionsForTopic("topic1") @@ -149,7 +193,7 @@ func testStoreSubscriptionsExpiring(t *testing.T, store webpush.Store, setUpdate require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) } -func testStoreRemoveExpiredSubscriptions(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { +func testStoreRemoveExpired(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) subs, err := store.SubscriptionsForTopic("topic1") From bdd20197b39ad41ea091a2c89273a77522a9e128 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 19:06:45 -0500 Subject: [PATCH 184/548] Manual refinements --- webpush/store.go | 25 ++++++++++++++++++++++--- webpush/store_postgres.go | 7 ++++--- webpush/store_sqlite.go | 22 ++-------------------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/webpush/store.go b/webpush/store.go index 5dd94c93..70dfc8fa 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -1,6 +1,7 @@ package webpush import ( + "database/sql" "errors" "net/netip" "time" @@ -9,9 +10,9 @@ import ( ) const ( - SubscriptionIDPrefix = "wps_" - SubscriptionIDLength = 10 - SubscriptionEndpointLimitPerSubscriberIP = 10 + subscriptionIDPrefix = "wps_" + subscriptionIDLength = 10 + subscriptionEndpointLimitPerSubscriberIP = 10 ) var ( @@ -50,3 +51,21 @@ func (w *Subscription) Context() log.Context { "web_push_subscription_endpoint": w.Endpoint, } } + +func subscriptionsFromRows(rows *sql.Rows) ([]*Subscription, error) { + subscriptions := make([]*Subscription, 0) + for rows.Next() { + var id, endpoint, auth, p256dh, userID string + if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil { + return nil, err + } + subscriptions = append(subscriptions, &Subscription{ + ID: id, + Endpoint: endpoint, + Auth: auth, + P256dh: p256dh, + UserID: userID, + }) + } + return subscriptions, nil +} diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index 1889f92f..a37169b6 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -2,6 +2,7 @@ package webpush import ( "database/sql" + "errors" "net/netip" "time" @@ -133,11 +134,11 @@ func (c *PostgresStore) UpsertSubscription(endpoint string, auth, p256dh, userID // Read existing subscription ID for endpoint (or create new ID) var subscriptionID string err = tx.QueryRow(pgSelectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID) - if err == sql.ErrNoRows { - if subscriptionCount >= SubscriptionEndpointLimitPerSubscriberIP { + if errors.Is(err, sql.ErrNoRows) { + if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { return ErrWebPushTooManySubscriptions } - subscriptionID = util.RandomStringPrefix(SubscriptionIDPrefix, SubscriptionIDLength) + subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) } else if err != nil { return err } diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 2483fa93..3fae75a6 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -166,10 +166,10 @@ func (c *SQLiteStore) UpsertSubscription(endpoint string, auth, p256dh, userID s return err } } else { - if subscriptionCount >= SubscriptionEndpointLimitPerSubscriberIP { + if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { return ErrWebPushTooManySubscriptions } - subscriptionID = util.RandomStringPrefix(SubscriptionIDPrefix, SubscriptionIDLength) + subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) } if err := rows.Close(); err != nil { return err @@ -262,21 +262,3 @@ func (c *SQLiteStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) func (c *SQLiteStore) Close() error { return c.db.Close() } - -func subscriptionsFromRows(rows *sql.Rows) ([]*Subscription, error) { - subscriptions := make([]*Subscription, 0) - for rows.Next() { - var id, endpoint, auth, p256dh, userID string - if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil { - return nil, err - } - subscriptions = append(subscriptions, &Subscription{ - ID: id, - Endpoint: endpoint, - Auth: auth, - P256dh: p256dh, - UserID: userID, - }) - } - return subscriptions, nil -} From 869b972a50894540aa570912bc1a1bd7cf73c290 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 19:12:14 -0500 Subject: [PATCH 185/548] Manual review --- webpush/store_postgres.go | 3 ++- webpush/store_sqlite.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index a37169b6..025e11c8 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -57,6 +57,7 @@ const ( DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at ` pgUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2` + pgUpdateSubscriptionUpdatedAtQuery = `UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2` pgDeleteSubscriptionByEndpointQuery = `DELETE FROM webpush_subscription WHERE endpoint = $1` pgDeleteSubscriptionByUserIDQuery = `DELETE FROM webpush_subscription WHERE user_id = $1` pgDeleteSubscriptionByAgeQuery = `DELETE FROM webpush_subscription WHERE updated_at <= $1` @@ -222,7 +223,7 @@ func (c *PostgresStore) RemoveExpiredSubscriptions(expireAfter time.Duration) er // SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is // exported for testing purposes and is not part of the Store interface. func (c *PostgresStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { - _, err := c.db.Exec("UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2", updatedAt, endpoint) + _, err := c.db.Exec(pgUpdateSubscriptionUpdatedAtQuery, updatedAt, endpoint) return err } diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 3fae75a6..bbd9be48 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -63,6 +63,7 @@ const ( DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at ` sqliteUpdateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?` + sqliteUpdateWebPushSubscriptionUpdatedAtQuery = `UPDATE subscription SET updated_at = ? WHERE endpoint = ?` sqliteDeleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?` sqliteDeleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?` sqliteDeleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan! @@ -254,7 +255,7 @@ func (c *SQLiteStore) RemoveExpiredSubscriptions(expireAfter time.Duration) erro // SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is // exported for testing purposes and is not part of the Store interface. func (c *SQLiteStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { - _, err := c.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", updatedAt, endpoint) + _, err := c.db.Exec(sqliteUpdateWebPushSubscriptionUpdatedAtQuery, updatedAt, endpoint) return err } From 4e5f95ba0c073bff35e2c0a748000718ff56d224 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 19:53:34 -0500 Subject: [PATCH 186/548] Refactor webpush store to eliminate code duplication Consolidate SQLite and Postgres store implementations into a single commonStore with database-specific SQL queries passed via configuration. This eliminates ~100 lines of duplicate code while maintaining full functionality for both backends. Key changes: - Move all store methods to commonStore in store.go - Remove sqliteStore and postgresStore wrapper structs - Refactor SQLite to use QueryRow() pattern like Postgres - Pass database-specific queries via storeQueries struct - Make store types unexported, only expose Store interface All tests pass for both SQLite and PostgreSQL backends. --- server/server.go | 2 +- webpush/store.go | 137 +++++++++++++++++++++++++++- webpush/store_postgres.go | 141 ++++------------------------- webpush/store_postgres_test.go | 2 +- webpush/store_sqlite.go | 161 ++++----------------------------- webpush/store_sqlite_test.go | 2 +- 6 files changed, 173 insertions(+), 272 deletions(-) diff --git a/server/server.go b/server/server.go index cba0179a..de8af35f 100644 --- a/server/server.go +++ b/server/server.go @@ -58,7 +58,7 @@ type Server struct { messagesHistory []int64 // Last n values of the messages counter, used to determine rate userManager *user.Manager // Might be nil! messageCache *messageCache // Database that stores the messages - webPush webpush.Store // Database that stores web push subscriptions + webPush webpush.Store // Database that stores web push subscriptions fileCache *fileCache // File system based cache that stores attachments stripe stripeAPI // Stripe API, can be replaced with a mock priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) diff --git a/webpush/store.go b/webpush/store.go index 70dfc8fa..048fe083 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -7,6 +7,7 @@ import ( "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/util" ) const ( @@ -16,7 +17,6 @@ const ( ) var ( - ErrWebPushNoRows = errors.New("no rows found") ErrWebPushTooManySubscriptions = errors.New("too many subscriptions") ErrWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty") ) @@ -34,6 +34,29 @@ type Store interface { Close() error } +// storeQueries holds the database-specific SQL queries. +type storeQueries struct { + selectSubscriptionIDByEndpoint string + selectSubscriptionCountBySubscriberIP string + selectSubscriptionsForTopic string + selectSubscriptionsExpiringSoon string + insertSubscription string + updateSubscriptionWarningSent string + updateSubscriptionUpdatedAt string + deleteSubscriptionByEndpoint string + deleteSubscriptionByUserID string + deleteSubscriptionByAge string + insertSubscriptionTopic string + deleteSubscriptionTopicAll string + deleteSubscriptionTopicWithoutSubscription string +} + +// commonStore implements store operations that are identical across database backends. +type commonStore struct { + db *sql.DB + queries storeQueries +} + // Subscription represents a web push subscription. type Subscription struct { ID string @@ -69,3 +92,115 @@ func subscriptionsFromRows(rows *sql.Rows) ([]*Subscription, error) { } return subscriptions, nil } + +// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. +func (s *commonStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // Read number of subscriptions for subscriber IP address + var subscriptionCount int + if err := tx.QueryRow(s.queries.selectSubscriptionCountBySubscriberIP, subscriberIP.String()).Scan(&subscriptionCount); err != nil { + return err + } + // Read existing subscription ID for endpoint (or create new ID) + var subscriptionID string + err = tx.QueryRow(s.queries.selectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID) + if errors.Is(err, sql.ErrNoRows) { + if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { + return ErrWebPushTooManySubscriptions + } + subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) + } else if err != nil { + return err + } + // Insert or update subscription + updatedAt, warnedAt := time.Now().Unix(), 0 + if _, err = tx.Exec(s.queries.insertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { + return err + } + // Replace all subscription topics + if _, err := tx.Exec(s.queries.deleteSubscriptionTopicAll, subscriptionID); err != nil { + return err + } + for _, topic := range topics { + if _, err = tx.Exec(s.queries.insertSubscriptionTopic, subscriptionID, topic); err != nil { + return err + } + } + return tx.Commit() +} + +// SubscriptionsForTopic returns all subscriptions for the given topic. +func (s *commonStore) SubscriptionsForTopic(topic string) ([]*Subscription, error) { + rows, err := s.db.Query(s.queries.selectSubscriptionsForTopic, topic) + if err != nil { + return nil, err + } + defer rows.Close() + return subscriptionsFromRows(rows) +} + +// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period. +func (s *commonStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) { + rows, err := s.db.Query(s.queries.selectSubscriptionsExpiringSoon, time.Now().Add(-warnAfter).Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + return subscriptionsFromRows(rows) +} + +// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon. +func (s *commonStore) MarkExpiryWarningSent(subscriptions []*Subscription) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, subscription := range subscriptions { + if _, err := tx.Exec(s.queries.updateSubscriptionWarningSent, time.Now().Unix(), subscription.ID); err != nil { + return err + } + } + return tx.Commit() +} + +// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint. +func (s *commonStore) RemoveSubscriptionsByEndpoint(endpoint string) error { + _, err := s.db.Exec(s.queries.deleteSubscriptionByEndpoint, endpoint) + return err +} + +// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID. +func (s *commonStore) RemoveSubscriptionsByUserID(userID string) error { + if userID == "" { + return ErrWebPushUserIDCannotBeEmpty + } + _, err := s.db.Exec(s.queries.deleteSubscriptionByUserID, userID) + return err +} + +// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period. +func (s *commonStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error { + _, err := s.db.Exec(s.queries.deleteSubscriptionByAge, time.Now().Add(-expireAfter).Unix()) + if err != nil { + return err + } + _, err = s.db.Exec(s.queries.deleteSubscriptionTopicWithoutSubscription) + return err +} + +// SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is +// exported for testing purposes and is not part of the Store interface. +func (s *commonStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { + _, err := s.db.Exec(s.queries.updateSubscriptionUpdatedAt, updatedAt, endpoint) + return err +} + +// Close closes the underlying database connection. +func (s *commonStore) Close() error { + return s.db.Close() +} diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index 025e11c8..da69c6e1 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -2,13 +2,8 @@ package webpush import ( "database/sql" - "errors" - "net/netip" - "time" _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver - - "heckel.io/ntfy/v2/util" ) const ( @@ -74,13 +69,8 @@ const ( pgSelectSchemaVersionQuery = `SELECT version FROM webpush_schema_version WHERE id = 1` ) -// PostgresStore is a web push subscription store backed by PostgreSQL. -type PostgresStore struct { - db *sql.DB -} - // NewPostgresStore creates a new PostgreSQL-backed web push store. -func NewPostgresStore(dsn string) (*PostgresStore, error) { +func NewPostgresStore(dsn string) (Store, error) { db, err := sql.Open("pgx", dsn) if err != nil { return nil, err @@ -91,8 +81,23 @@ func NewPostgresStore(dsn string) (*PostgresStore, error) { if err := setupPostgresDB(db); err != nil { return nil, err } - return &PostgresStore{ + return &commonStore{ db: db, + queries: storeQueries{ + selectSubscriptionIDByEndpoint: pgSelectSubscriptionIDByEndpoint, + selectSubscriptionCountBySubscriberIP: pgSelectSubscriptionCountBySubscriberIP, + selectSubscriptionsForTopic: pgSelectSubscriptionsForTopicQuery, + selectSubscriptionsExpiringSoon: pgSelectSubscriptionsExpiringSoonQuery, + insertSubscription: pgInsertSubscriptionQuery, + updateSubscriptionWarningSent: pgUpdateSubscriptionWarningSentQuery, + updateSubscriptionUpdatedAt: pgUpdateSubscriptionUpdatedAtQuery, + deleteSubscriptionByEndpoint: pgDeleteSubscriptionByEndpointQuery, + deleteSubscriptionByUserID: pgDeleteSubscriptionByUserIDQuery, + deleteSubscriptionByAge: pgDeleteSubscriptionByAgeQuery, + insertSubscriptionTopic: pgInsertSubscriptionTopicQuery, + deleteSubscriptionTopicAll: pgDeleteSubscriptionTopicAllQuery, + deleteSubscriptionTopicWithoutSubscription: pgDeleteSubscriptionTopicWithoutSubscription, + }, }, nil } @@ -119,115 +124,3 @@ func setupNewPostgresDB(db *sql.DB) error { } return tx.Commit() } - -// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. -func (c *PostgresStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - // Read number of subscriptions for subscriber IP address - var subscriptionCount int - if err := tx.QueryRow(pgSelectSubscriptionCountBySubscriberIP, subscriberIP.String()).Scan(&subscriptionCount); err != nil { - return err - } - // Read existing subscription ID for endpoint (or create new ID) - var subscriptionID string - err = tx.QueryRow(pgSelectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID) - if errors.Is(err, sql.ErrNoRows) { - if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { - return ErrWebPushTooManySubscriptions - } - subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) - } else if err != nil { - return err - } - // Insert or update subscription - updatedAt, warnedAt := time.Now().Unix(), 0 - if _, err = tx.Exec(pgInsertSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { - return err - } - // Replace all subscription topics - if _, err := tx.Exec(pgDeleteSubscriptionTopicAllQuery, subscriptionID); err != nil { - return err - } - for _, topic := range topics { - if _, err = tx.Exec(pgInsertSubscriptionTopicQuery, subscriptionID, topic); err != nil { - return err - } - } - return tx.Commit() -} - -// SubscriptionsForTopic returns all subscriptions for the given topic. -func (c *PostgresStore) SubscriptionsForTopic(topic string) ([]*Subscription, error) { - rows, err := c.db.Query(pgSelectSubscriptionsForTopicQuery, topic) - if err != nil { - return nil, err - } - defer rows.Close() - return subscriptionsFromRows(rows) -} - -// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period. -func (c *PostgresStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) { - rows, err := c.db.Query(pgSelectSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix()) - if err != nil { - return nil, err - } - defer rows.Close() - return subscriptionsFromRows(rows) -} - -// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon. -func (c *PostgresStore) MarkExpiryWarningSent(subscriptions []*Subscription) error { - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, subscription := range subscriptions { - if _, err := tx.Exec(pgUpdateSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil { - return err - } - } - return tx.Commit() -} - -// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint. -func (c *PostgresStore) RemoveSubscriptionsByEndpoint(endpoint string) error { - _, err := c.db.Exec(pgDeleteSubscriptionByEndpointQuery, endpoint) - return err -} - -// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID. -func (c *PostgresStore) RemoveSubscriptionsByUserID(userID string) error { - if userID == "" { - return ErrWebPushUserIDCannotBeEmpty - } - _, err := c.db.Exec(pgDeleteSubscriptionByUserIDQuery, userID) - return err -} - -// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period. -func (c *PostgresStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error { - _, err := c.db.Exec(pgDeleteSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix()) - if err != nil { - return err - } - _, err = c.db.Exec(pgDeleteSubscriptionTopicWithoutSubscription) - return err -} - -// SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is -// exported for testing purposes and is not part of the Store interface. -func (c *PostgresStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { - _, err := c.db.Exec(pgUpdateSubscriptionUpdatedAtQuery, updatedAt, endpoint) - return err -} - -// Close closes the underlying database connection. -func (c *PostgresStore) Close() error { - return c.db.Close() -} diff --git a/webpush/store_postgres_test.go b/webpush/store_postgres_test.go index 07a3c6be..0124441c 100644 --- a/webpush/store_postgres_test.go +++ b/webpush/store_postgres_test.go @@ -12,7 +12,7 @@ import ( "heckel.io/ntfy/v2/webpush" ) -func newTestPostgresStore(t *testing.T) *webpush.PostgresStore { +func newTestPostgresStore(t *testing.T) webpush.Store { dsn := os.Getenv("NTFY_TEST_DATABASE_URL") if dsn == "" { t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index bbd9be48..97bacf74 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -2,12 +2,8 @@ package webpush import ( "database/sql" - "net/netip" - "time" _ "github.com/mattn/go-sqlite3" // SQLite driver - - "heckel.io/ntfy/v2/util" ) const ( @@ -80,13 +76,8 @@ const ( sqliteSelectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` ) -// SQLiteStore is a web push subscription store backed by SQLite. -type SQLiteStore struct { - db *sql.DB -} - // NewSQLiteStore creates a new SQLite-backed web push store. -func NewSQLiteStore(filename, startupQueries string) (*SQLiteStore, error) { +func NewSQLiteStore(filename, startupQueries string) (Store, error) { db, err := sql.Open("sqlite3", filename) if err != nil { return nil, err @@ -97,8 +88,23 @@ func NewSQLiteStore(filename, startupQueries string) (*SQLiteStore, error) { if err := runSQLiteWebPushStartupQueries(db, startupQueries); err != nil { return nil, err } - return &SQLiteStore{ + return &commonStore{ db: db, + queries: storeQueries{ + selectSubscriptionIDByEndpoint: sqliteSelectWebPushSubscriptionIDByEndpoint, + selectSubscriptionCountBySubscriberIP: sqliteSelectWebPushSubscriptionCountBySubscriberIP, + selectSubscriptionsForTopic: sqliteSelectWebPushSubscriptionsForTopicQuery, + selectSubscriptionsExpiringSoon: sqliteSelectWebPushSubscriptionsExpiringSoonQuery, + insertSubscription: sqliteInsertWebPushSubscriptionQuery, + updateSubscriptionWarningSent: sqliteUpdateWebPushSubscriptionWarningSentQuery, + updateSubscriptionUpdatedAt: sqliteUpdateWebPushSubscriptionUpdatedAtQuery, + deleteSubscriptionByEndpoint: sqliteDeleteWebPushSubscriptionByEndpointQuery, + deleteSubscriptionByUserID: sqliteDeleteWebPushSubscriptionByUserIDQuery, + deleteSubscriptionByAge: sqliteDeleteWebPushSubscriptionByAgeQuery, + insertSubscriptionTopic: sqliteInsertWebPushSubscriptionTopicQuery, + deleteSubscriptionTopicAll: sqliteDeleteWebPushSubscriptionTopicAllQuery, + deleteSubscriptionTopicWithoutSubscription: sqliteDeleteWebPushSubscriptionTopicWithoutSubscription, + }, }, nil } @@ -130,136 +136,3 @@ func runSQLiteWebPushStartupQueries(db *sql.DB, startupQueries string) error { } return nil } - -// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all -// existing entries for a given endpoint. -func (c *SQLiteStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - // Read number of subscriptions for subscriber IP address - rowsCount, err := tx.Query(sqliteSelectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String()) - if err != nil { - return err - } - defer rowsCount.Close() - var subscriptionCount int - if !rowsCount.Next() { - return ErrWebPushNoRows - } - if err := rowsCount.Scan(&subscriptionCount); err != nil { - return err - } - if err := rowsCount.Close(); err != nil { - return err - } - // Read existing subscription ID for endpoint (or create new ID) - rows, err := tx.Query(sqliteSelectWebPushSubscriptionIDByEndpoint, endpoint) - if err != nil { - return err - } - defer rows.Close() - var subscriptionID string - if rows.Next() { - if err := rows.Scan(&subscriptionID); err != nil { - return err - } - } else { - if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { - return ErrWebPushTooManySubscriptions - } - subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) - } - if err := rows.Close(); err != nil { - return err - } - // Insert or update subscription - updatedAt, warnedAt := time.Now().Unix(), 0 - if _, err = tx.Exec(sqliteInsertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { - return err - } - // Replace all subscription topics - if _, err := tx.Exec(sqliteDeleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil { - return err - } - for _, topic := range topics { - if _, err = tx.Exec(sqliteInsertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil { - return err - } - } - return tx.Commit() -} - -// SubscriptionsForTopic returns all subscriptions for the given topic. -func (c *SQLiteStore) SubscriptionsForTopic(topic string) ([]*Subscription, error) { - rows, err := c.db.Query(sqliteSelectWebPushSubscriptionsForTopicQuery, topic) - if err != nil { - return nil, err - } - defer rows.Close() - return subscriptionsFromRows(rows) -} - -// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period. -func (c *SQLiteStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) { - rows, err := c.db.Query(sqliteSelectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix()) - if err != nil { - return nil, err - } - defer rows.Close() - return subscriptionsFromRows(rows) -} - -// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon. -func (c *SQLiteStore) MarkExpiryWarningSent(subscriptions []*Subscription) error { - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, subscription := range subscriptions { - if _, err := tx.Exec(sqliteUpdateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil { - return err - } - } - return tx.Commit() -} - -// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint. -func (c *SQLiteStore) RemoveSubscriptionsByEndpoint(endpoint string) error { - _, err := c.db.Exec(sqliteDeleteWebPushSubscriptionByEndpointQuery, endpoint) - return err -} - -// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID. -func (c *SQLiteStore) RemoveSubscriptionsByUserID(userID string) error { - if userID == "" { - return ErrWebPushUserIDCannotBeEmpty - } - _, err := c.db.Exec(sqliteDeleteWebPushSubscriptionByUserIDQuery, userID) - return err -} - -// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period. -func (c *SQLiteStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error { - _, err := c.db.Exec(sqliteDeleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix()) - if err != nil { - return err - } - _, err = c.db.Exec(sqliteDeleteWebPushSubscriptionTopicWithoutSubscription) - return err -} - -// SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is -// exported for testing purposes and is not part of the Store interface. -func (c *SQLiteStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { - _, err := c.db.Exec(sqliteUpdateWebPushSubscriptionUpdatedAtQuery, updatedAt, endpoint) - return err -} - -// Close closes the underlying database connection. -func (c *SQLiteStore) Close() error { - return c.db.Close() -} diff --git a/webpush/store_sqlite_test.go b/webpush/store_sqlite_test.go index b01ca55d..1d4087d1 100644 --- a/webpush/store_sqlite_test.go +++ b/webpush/store_sqlite_test.go @@ -8,7 +8,7 @@ import ( "heckel.io/ntfy/v2/webpush" ) -func newTestSQLiteStore(t *testing.T) *webpush.SQLiteStore { +func newTestSQLiteStore(t *testing.T) webpush.Store { store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") require.Nil(t, err) t.Cleanup(func() { store.Close() }) From 82e15d84bdb470665849a070d51019e5ff1d157f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 20:02:19 -0500 Subject: [PATCH 187/548] Manual changes --- webpush/store.go | 56 ++++++++++++++++-------------------------------- webpush/types.go | 21 ++++++++++++++++++ 2 files changed, 40 insertions(+), 37 deletions(-) create mode 100644 webpush/types.go diff --git a/webpush/store.go b/webpush/store.go index 048fe083..a318f2b4 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -6,7 +6,6 @@ import ( "net/netip" "time" - "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" ) @@ -16,6 +15,7 @@ const ( subscriptionEndpointLimitPerSubscriberIP = 10 ) +// Errors returned by the store var ( ErrWebPushTooManySubscriptions = errors.New("too many subscriptions") ErrWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty") @@ -57,42 +57,6 @@ type commonStore struct { queries storeQueries } -// Subscription represents a web push subscription. -type Subscription struct { - ID string - Endpoint string - Auth string - P256dh string - UserID string -} - -// Context returns the logging context for the subscription. -func (w *Subscription) Context() log.Context { - return map[string]any{ - "web_push_subscription_id": w.ID, - "web_push_subscription_user_id": w.UserID, - "web_push_subscription_endpoint": w.Endpoint, - } -} - -func subscriptionsFromRows(rows *sql.Rows) ([]*Subscription, error) { - subscriptions := make([]*Subscription, 0) - for rows.Next() { - var id, endpoint, auth, p256dh, userID string - if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil { - return nil, err - } - subscriptions = append(subscriptions, &Subscription{ - ID: id, - Endpoint: endpoint, - Auth: auth, - P256dh: p256dh, - UserID: userID, - }) - } - return subscriptions, nil -} - // UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. func (s *commonStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { tx, err := s.db.Begin() @@ -204,3 +168,21 @@ func (s *commonStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) func (s *commonStore) Close() error { return s.db.Close() } + +func subscriptionsFromRows(rows *sql.Rows) ([]*Subscription, error) { + subscriptions := make([]*Subscription, 0) + for rows.Next() { + var id, endpoint, auth, p256dh, userID string + if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil { + return nil, err + } + subscriptions = append(subscriptions, &Subscription{ + ID: id, + Endpoint: endpoint, + Auth: auth, + P256dh: p256dh, + UserID: userID, + }) + } + return subscriptions, nil +} diff --git a/webpush/types.go b/webpush/types.go new file mode 100644 index 00000000..e438dcbe --- /dev/null +++ b/webpush/types.go @@ -0,0 +1,21 @@ +package webpush + +import "heckel.io/ntfy/v2/log" + +// Subscription represents a web push subscription. +type Subscription struct { + ID string + Endpoint string + Auth string + P256dh string + UserID string +} + +// Context returns the logging context for the subscription. +func (w *Subscription) Context() log.Context { + return map[string]any{ + "web_push_subscription_id": w.ID, + "web_push_subscription_user_id": w.UserID, + "web_push_subscription_endpoint": w.Endpoint, + } +} From 3d72845c816cb720c0beff14a70bb37c417e492d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 20:49:17 -0500 Subject: [PATCH 188/548] Tests for user package --- user/util_test.go | 281 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 user/util_test.go diff --git a/user/util_test.go b/user/util_test.go new file mode 100644 index 00000000..97c4bc4a --- /dev/null +++ b/user/util_test.go @@ -0,0 +1,281 @@ +package user + +import ( + "github.com/stretchr/testify/require" + "strings" + "testing" +) + +func TestAllowedRole(t *testing.T) { + require.True(t, AllowedRole(RoleUser)) + require.True(t, AllowedRole(RoleAdmin)) + require.False(t, AllowedRole(RoleAnonymous)) + require.False(t, AllowedRole(Role("invalid"))) + require.False(t, AllowedRole(Role(""))) + require.False(t, AllowedRole(Role("superadmin"))) +} + +func TestAllowedTopic(t *testing.T) { + // Valid topics + require.True(t, AllowedTopic("test")) + require.True(t, AllowedTopic("mytopic")) + require.True(t, AllowedTopic("topic123")) + require.True(t, AllowedTopic("my-topic")) + require.True(t, AllowedTopic("my_topic")) + require.True(t, AllowedTopic("Topic123")) + require.True(t, AllowedTopic("a")) + require.True(t, AllowedTopic(strings.Repeat("a", 64))) // Max length + + // Invalid topics - wildcards not allowed + require.False(t, AllowedTopic("topic*")) + require.False(t, AllowedTopic("*")) + require.False(t, AllowedTopic("my*topic")) + + // Invalid topics - special characters + require.False(t, AllowedTopic("my topic")) // Space + require.False(t, AllowedTopic("my.topic")) // Dot + require.False(t, AllowedTopic("my/topic")) // Slash + require.False(t, AllowedTopic("my@topic")) // At sign + require.False(t, AllowedTopic("my+topic")) // Plus + require.False(t, AllowedTopic("topic!")) // Exclamation + require.False(t, AllowedTopic("topic#")) // Hash + require.False(t, AllowedTopic("topic$")) // Dollar + require.False(t, AllowedTopic("topic%")) // Percent + require.False(t, AllowedTopic("topic&")) // Ampersand + require.False(t, AllowedTopic("my\\topic")) // Backslash + + // Invalid topics - length + require.False(t, AllowedTopic("")) // Empty + require.False(t, AllowedTopic(strings.Repeat("a", 65))) // Too long +} + +func TestAllowedTopicPattern(t *testing.T) { + // Valid patterns - same as AllowedTopic + require.True(t, AllowedTopicPattern("test")) + require.True(t, AllowedTopicPattern("mytopic")) + require.True(t, AllowedTopicPattern("topic123")) + require.True(t, AllowedTopicPattern("my-topic")) + require.True(t, AllowedTopicPattern("my_topic")) + require.True(t, AllowedTopicPattern("a")) + require.True(t, AllowedTopicPattern(strings.Repeat("a", 64))) // Max length + + // Valid patterns - with wildcards + require.True(t, AllowedTopicPattern("*")) + require.True(t, AllowedTopicPattern("topic*")) + require.True(t, AllowedTopicPattern("*topic")) + require.True(t, AllowedTopicPattern("my*topic")) + require.True(t, AllowedTopicPattern("***")) + require.True(t, AllowedTopicPattern("test_*")) + require.True(t, AllowedTopicPattern("my-*-topic")) + require.True(t, AllowedTopicPattern(strings.Repeat("*", 64))) // Max length with wildcards + + // Invalid patterns - special characters (other than wildcard) + require.False(t, AllowedTopicPattern("my topic")) // Space + require.False(t, AllowedTopicPattern("my.topic")) // Dot + require.False(t, AllowedTopicPattern("my/topic")) // Slash + require.False(t, AllowedTopicPattern("my@topic")) // At sign + require.False(t, AllowedTopicPattern("my+topic")) // Plus + require.False(t, AllowedTopicPattern("topic!")) // Exclamation + require.False(t, AllowedTopicPattern("topic#")) // Hash + require.False(t, AllowedTopicPattern("topic$")) // Dollar + require.False(t, AllowedTopicPattern("topic%")) // Percent + require.False(t, AllowedTopicPattern("topic&")) // Ampersand + require.False(t, AllowedTopicPattern("my\\topic")) // Backslash + + // Invalid patterns - length + require.False(t, AllowedTopicPattern("")) // Empty + require.False(t, AllowedTopicPattern(strings.Repeat("a", 65))) // Too long +} + +func TestValidPasswordHash(t *testing.T) { + // Valid bcrypt hashes with different versions + require.Nil(t, ValidPasswordHash("$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10)) + require.Nil(t, ValidPasswordHash("$2b$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", 10)) + require.Nil(t, ValidPasswordHash("$2y$12$1234567890123456789012u1234567890123456789012345678901", 10)) + + // Valid hash with minimum cost + require.Nil(t, ValidPasswordHash("$2a$04$1234567890123456789012u1234567890123456789012345678901", 4)) + + // Invalid - wrong prefix + require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("$2c$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10)) + require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("$3a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10)) + require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("bcrypt$10$hash", 10)) + require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("nothash", 10)) + require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("", 10)) + + // Invalid - malformed hash + require.NotNil(t, ValidPasswordHash("$2a$10$tooshort", 10)) + require.NotNil(t, ValidPasswordHash("$2a$10", 10)) + require.NotNil(t, ValidPasswordHash("$2a$", 10)) + + // Invalid - cost too low + require.Equal(t, ErrPasswordHashWeak, ValidPasswordHash("$2a$04$1234567890123456789012u1234567890123456789012345678901", 10)) + require.Equal(t, ErrPasswordHashWeak, ValidPasswordHash("$2a$09$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10)) + + // Edge case - cost exactly at minimum + require.Nil(t, ValidPasswordHash("$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10)) +} + +func TestValidToken(t *testing.T) { + // Valid tokens + require.True(t, ValidToken("tk_1234567890123456789012345678x")) + require.True(t, ValidToken("tk_abcdefghijklmnopqrstuvwxyzabc")) + require.True(t, ValidToken("tk_ABCDEFGHIJKLMNOPQRSTUVWXYZABC")) + require.True(t, ValidToken("tk_012345678901234567890123456ab")) + require.True(t, ValidToken("tk_-----------------------------")) + require.True(t, ValidToken("tk______________________________")) + + // Invalid tokens - wrong prefix + require.False(t, ValidToken("tx_1234567890123456789012345678x")) + require.False(t, ValidToken("tk1234567890123456789012345678xy")) + require.False(t, ValidToken("token_1234567890123456789012345")) + + // Invalid tokens - wrong length + require.False(t, ValidToken("tk_")) // Too short + require.False(t, ValidToken("tk_123")) // Too short + require.False(t, ValidToken("tk_123456789012345678901234567890")) // Too long (30 chars after prefix) + require.False(t, ValidToken("tk_123456789012345678901234567")) // Too short (28 chars) + + // Invalid tokens - invalid characters + require.False(t, ValidToken("tk_123456789012345678901234567!@")) + require.False(t, ValidToken("tk_12345678901234567890123456 8x")) + require.False(t, ValidToken("tk_123456789012345678901234567.x")) + require.False(t, ValidToken("tk_123456789012345678901234567*x")) + + // Invalid tokens - no prefix + require.False(t, ValidToken("1234567890123456789012345678901x")) + require.False(t, ValidToken("")) +} + +func TestGenerateToken(t *testing.T) { + // Generate multiple tokens + tokens := make(map[string]bool) + for i := 0; i < 100; i++ { + token := GenerateToken() + + // Check format + require.True(t, strings.HasPrefix(token, "tk_"), "Token should start with tk_") + require.Equal(t, 32, len(token), "Token should be 32 characters long") + + // Check it's valid + require.True(t, ValidToken(token), "Generated token should be valid") + + // Check it's lowercase + require.Equal(t, strings.ToLower(token), token, "Token should be lowercase") + + // Check uniqueness + require.False(t, tokens[token], "Token should be unique") + tokens[token] = true + } + + // Verify we got 100 unique tokens + require.Equal(t, 100, len(tokens)) +} + +func TestHashPassword(t *testing.T) { + password := "test-password-123" + + // Hash the password + hash, err := HashPassword(password) + require.Nil(t, err) + require.NotEmpty(t, hash) + + // Check it's a valid bcrypt hash + require.Nil(t, ValidPasswordHash(hash, DefaultUserPasswordBcryptCost)) + + // Check it starts with correct prefix + require.True(t, strings.HasPrefix(hash, "$2a$")) + + // Hash the same password again - should produce different hash + hash2, err := HashPassword(password) + require.Nil(t, err) + require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)") + + // Empty password should still work + emptyHash, err := HashPassword("") + require.Nil(t, err) + require.NotEmpty(t, emptyHash) + require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost)) +} + +func TestHashPassword_WithCost(t *testing.T) { + password := "test-password" + + // Test with different costs + hash4, err := hashPassword(password, 4) + require.Nil(t, err) + require.True(t, strings.HasPrefix(hash4, "$2a$04$")) + + hash10, err := hashPassword(password, 10) + require.Nil(t, err) + require.True(t, strings.HasPrefix(hash10, "$2a$10$")) + + hash12, err := hashPassword(password, 12) + require.Nil(t, err) + require.True(t, strings.HasPrefix(hash12, "$2a$12$")) + + // All should be valid + require.Nil(t, ValidPasswordHash(hash4, 4)) + require.Nil(t, ValidPasswordHash(hash10, 10)) + require.Nil(t, ValidPasswordHash(hash12, 12)) +} + +func TestUser_TierID(t *testing.T) { + // User with tier + u := &User{ + Tier: &Tier{ + ID: "ti_123", + Code: "pro", + }, + } + require.Equal(t, "ti_123", u.TierID()) + + // User without tier + u2 := &User{ + Tier: nil, + } + require.Equal(t, "", u2.TierID()) + + // Nil user + var u3 *User + require.Equal(t, "", u3.TierID()) +} + +func TestUser_IsAdmin(t *testing.T) { + admin := &User{Role: RoleAdmin} + require.True(t, admin.IsAdmin()) + require.False(t, admin.IsUser()) + + user := &User{Role: RoleUser} + require.False(t, user.IsAdmin()) + + anonymous := &User{Role: RoleAnonymous} + require.False(t, anonymous.IsAdmin()) + + // Nil user + var nilUser *User + require.False(t, nilUser.IsAdmin()) +} + +func TestUser_IsUser(t *testing.T) { + user := &User{Role: RoleUser} + require.True(t, user.IsUser()) + require.False(t, user.IsAdmin()) + + admin := &User{Role: RoleAdmin} + require.False(t, admin.IsUser()) + + anonymous := &User{Role: RoleAnonymous} + require.False(t, anonymous.IsUser()) + + // Nil user + var nilUser *User + require.False(t, nilUser.IsUser()) +} + +func TestPermission_String(t *testing.T) { + require.Equal(t, "read-write", PermissionReadWrite.String()) + require.Equal(t, "read-only", PermissionRead.String()) + require.Equal(t, "write-only", PermissionWrite.String()) + require.Equal(t, "deny-all", PermissionDenyAll.String()) +} From ceda5ec3d8d1300efff76d9ff8c41ca4dab6f585 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 21:04:15 -0500 Subject: [PATCH 189/548] Move things in user package --- user/manager.go | 415 +-------------------------------------------- user/migrations.go | 342 +++++++++++++++++++++++++++++++++++++ user/util.go | 72 +++++++- 3 files changed, 419 insertions(+), 410 deletions(-) create mode 100644 user/migrations.go diff --git a/user/manager.go b/user/manager.go index 1d900604..59aa883a 100644 --- a/user/manager.go +++ b/user/manager.go @@ -6,17 +6,17 @@ import ( "encoding/json" "errors" "fmt" + "net/netip" + "path/filepath" + "slices" + "sync" + "time" + "github.com/mattn/go-sqlite3" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/util" - "net/netip" - "path/filepath" - "slices" - "strings" - "sync" - "time" ) const ( @@ -326,229 +326,6 @@ const ( ` ) -// Schema management queries -const ( - currentSchemaVersion = 6 - insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` - selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` - - // 1 -> 2 (complex migration!) - migrate1To2CreateTablesQueries = ` - ALTER TABLE user RENAME TO user_old; - CREATE TABLE IF NOT EXISTS tier ( - id TEXT PRIMARY KEY, - code TEXT NOT NULL, - name TEXT NOT NULL, - messages_limit INT NOT NULL, - messages_expiry_duration INT NOT NULL, - emails_limit INT NOT NULL, - reservations_limit INT NOT NULL, - attachment_file_size_limit INT NOT NULL, - attachment_total_size_limit INT NOT NULL, - attachment_expiry_duration INT NOT NULL, - attachment_bandwidth_limit INT NOT NULL, - stripe_price_id TEXT - ); - CREATE UNIQUE INDEX idx_tier_code ON tier (code); - CREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id); - CREATE TABLE IF NOT EXISTS user ( - id TEXT PRIMARY KEY, - tier_id TEXT, - user TEXT NOT NULL, - pass TEXT NOT NULL, - role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, - prefs JSON NOT NULL DEFAULT '{}', - sync_topic TEXT NOT NULL, - stats_messages INT NOT NULL DEFAULT (0), - stats_emails INT NOT NULL DEFAULT (0), - stripe_customer_id TEXT, - stripe_subscription_id TEXT, - stripe_subscription_status TEXT, - stripe_subscription_paid_until INT, - stripe_subscription_cancel_at INT, - created INT NOT NULL, - deleted INT, - FOREIGN KEY (tier_id) REFERENCES tier (id) - ); - CREATE UNIQUE INDEX idx_user ON user (user); - 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 TABLE IF NOT EXISTS user_access ( - user_id TEXT NOT NULL, - topic TEXT NOT NULL, - read INT NOT NULL, - write INT NOT NULL, - owner_user_id INT, - PRIMARY KEY (user_id, topic), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, - FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS user_token ( - user_id TEXT NOT NULL, - token TEXT NOT NULL, - label TEXT NOT NULL, - last_access INT NOT NULL, - last_origin TEXT NOT NULL, - expires INT NOT NULL, - 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; - ` - migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old` - migrate1To2InsertUserNoTx = ` - INSERT INTO user (id, user, pass, role, sync_topic, created) - SELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ? - ` - migrate1To2InsertFromOldTablesAndDropNoTx = ` - INSERT INTO user_access (user_id, topic, read, write) - SELECT u.id, a.topic, a.read, a.write - FROM user u - JOIN access a ON u.user = a.user; - - DROP TABLE access; - DROP TABLE user_old; - ` - - // 2 -> 3 - migrate2To3UpdateQueries = ` - ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT; - ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id; - ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT; - DROP INDEX IF EXISTS idx_tier_price_id; - CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); - CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); - ` - - // 3 -> 4 - migrate3To4UpdateQueries = ` - ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0); - ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0); - CREATE TABLE IF NOT EXISTS 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 - ); - ` - - // 4 -> 5 - migrate4To5UpdateQueries = ` - UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); - ` - - // 5 -> 6 - migrate5To6UpdateQueries = ` - PRAGMA foreign_keys=off; - - -- Alter user table: Add provisioned column - ALTER TABLE user RENAME TO user_old; - CREATE TABLE IF NOT EXISTS user ( - id TEXT PRIMARY KEY, - tier_id TEXT, - user TEXT NOT NULL, - pass TEXT NOT NULL, - role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, - prefs JSON NOT NULL DEFAULT '{}', - sync_topic TEXT NOT NULL, - provisioned INT NOT NULL, - stats_messages INT NOT NULL DEFAULT (0), - stats_emails INT NOT NULL DEFAULT (0), - stats_calls INT NOT NULL DEFAULT (0), - stripe_customer_id TEXT, - stripe_subscription_id TEXT, - stripe_subscription_status TEXT, - stripe_subscription_interval TEXT, - stripe_subscription_paid_until INT, - stripe_subscription_cancel_at INT, - created INT NOT NULL, - deleted INT, - FOREIGN KEY (tier_id) REFERENCES tier (id) - ); - INSERT INTO user - SELECT - id, - tier_id, - user, - pass, - role, - prefs, - sync_topic, - 0, -- provisioned - stats_messages, - stats_emails, - stats_calls, - stripe_customer_id, - stripe_subscription_id, - stripe_subscription_status, - stripe_subscription_interval, - stripe_subscription_paid_until, - stripe_subscription_cancel_at, - created, - deleted - FROM user_old; - DROP TABLE user_old; - - -- Alter user_access table: Add provisioned column - ALTER TABLE user_access RENAME TO user_access_old; - CREATE TABLE user_access ( - user_id TEXT NOT NULL, - topic TEXT NOT NULL, - read INT NOT NULL, - write INT NOT NULL, - owner_user_id INT, - provisioned INTEGER NOT NULL, - PRIMARY KEY (user_id, topic), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, - FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE - ); - INSERT INTO user_access SELECT *, 0 FROM user_access_old; - DROP TABLE user_access_old; - - -- Alter user_token table: Add provisioned column - ALTER TABLE user_token RENAME TO user_token_old; - CREATE TABLE IF NOT EXISTS user_token ( - user_id TEXT NOT NULL, - token TEXT NOT NULL, - label TEXT NOT NULL, - last_access INT NOT NULL, - last_origin TEXT NOT NULL, - expires INT NOT NULL, - provisioned INT NOT NULL, - PRIMARY KEY (user_id, token), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - INSERT INTO user_token SELECT *, 0 FROM user_token_old; - DROP TABLE user_token_old; - - -- Recreate indices - CREATE UNIQUE INDEX idx_user ON user (user); - 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; - ` -) - -var ( - migrations = map[int]func(db *sql.DB) error{ - 1: migrateFrom1, - 2: migrateFrom2, - 3: migrateFrom3, - 4: migrateFrom4, - 5: migrateFrom5, - } -) - // Manager is an implementation of Manager. It stores users and access control list // in a SQLite database. type Manager struct { @@ -1840,7 +1617,7 @@ func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, e return nil } -// maybyProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config. +// maybeProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config. // // Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last // access time) or do not have dependent resources (such as grants or tokens). @@ -1909,26 +1686,6 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string) return nil } -// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards, -// and escapes '_', assuming '\' as escape character. -func toSQLWildcard(s string) string { - return escapeUnderscore(strings.ReplaceAll(s, "*", "%")) -} - -// fromSQLWildcard converts a SQL wildcard string to a wildcard string. It converts '%' to '*', -// and removes the '\_' escape character. -func fromSQLWildcard(s string) string { - return strings.ReplaceAll(unescapeUnderscore(s), "%", "*") -} - -func escapeUnderscore(s string) string { - return strings.ReplaceAll(s, "_", "\\_") -} - -func unescapeUnderscore(s string) string { - return strings.ReplaceAll(s, "\\_", "_") -} - func runStartupQueries(db *sql.DB, startupQueries string) error { if _, err := db.Exec(startupQueries); err != nil { return err @@ -1983,161 +1740,3 @@ func setupNewDB(db *sql.DB) error { } return nil } - -func migrateFrom1(db *sql.DB) error { - log.Tag(tag).Info("Migrating user database schema: from 1 to 2") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - // Rename user -> user_old, and create new tables - if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil { - return err - } - // Insert users from user_old into new user table, with ID and sync_topic - rows, err := tx.Query(migrate1To2SelectAllOldUsernamesNoTx) - 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 - } - 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(migrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil { - return err - } - } - // Migrate old "access" table to "user_access" and drop "access" and "user_old" - if _, err := tx.Exec(migrate1To2InsertFromOldTablesAndDropNoTx); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 2); err != nil { - return err - } - if err := tx.Commit(); err != nil { - return err - } - return nil -} - -func migrateFrom2(db *sql.DB) error { - log.Tag(tag).Info("Migrating user database schema: from 2 to 3") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 3); err != nil { - return err - } - return tx.Commit() -} - -func migrateFrom3(db *sql.DB) error { - log.Tag(tag).Info("Migrating user database schema: from 3 to 4") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 4); err != nil { - return err - } - return tx.Commit() -} - -func migrateFrom4(db *sql.DB) error { - log.Tag(tag).Info("Migrating user database schema: from 4 to 5") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate4To5UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 5); err != nil { - return err - } - return tx.Commit() -} - -func migrateFrom5(db *sql.DB) error { - log.Tag(tag).Info("Migrating user database schema: from 5 to 6") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 6); err != nil { - return err - } - return tx.Commit() -} - -func nullString(s string) sql.NullString { - if s == "" { - return sql.NullString{} - } - return sql.NullString{String: s, Valid: true} -} - -func nullInt64(v int64) sql.NullInt64 { - if v == 0 { - return sql.NullInt64{} - } - return sql.NullInt64{Int64: v, Valid: true} -} - -// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back. -func execTx(db *sql.DB, f func(tx *sql.Tx) error) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := f(tx); err != nil { - return err - } - return tx.Commit() -} - -// queryTx executes a function in a transaction and returns the result. If the function -// returns an error, the transaction is rolled back. -func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) { - tx, err := db.Begin() - if err != nil { - var zero T - return zero, err - } - defer tx.Rollback() - t, err := f(tx) - if err != nil { - return t, err - } - if err := tx.Commit(); err != nil { - return t, err - } - return t, nil -} diff --git a/user/migrations.go b/user/migrations.go new file mode 100644 index 00000000..a7eb317f --- /dev/null +++ b/user/migrations.go @@ -0,0 +1,342 @@ +package user + +import ( + "database/sql" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/util" +) + +// Schema management queries +const ( + currentSchemaVersion = 6 + insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` + selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` + + // 1 -> 2 (complex migration!) + migrate1To2CreateTablesQueries = ` + ALTER TABLE user RENAME TO user_old; + CREATE TABLE IF NOT EXISTS tier ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + name TEXT NOT NULL, + messages_limit INT NOT NULL, + messages_expiry_duration INT NOT NULL, + emails_limit INT NOT NULL, + reservations_limit INT NOT NULL, + attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, + attachment_expiry_duration INT NOT NULL, + attachment_bandwidth_limit INT NOT NULL, + stripe_price_id TEXT + ); + CREATE UNIQUE INDEX idx_tier_code ON tier (code); + CREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id); + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + tier_id TEXT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', + sync_topic TEXT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), + stats_emails INT NOT NULL DEFAULT (0), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT, + stripe_subscription_paid_until INT, + stripe_subscription_cancel_at INT, + created INT NOT NULL, + deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + 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 TABLE IF NOT EXISTS user_access ( + user_id TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + owner_user_id INT, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_token ( + user_id TEXT NOT NULL, + token TEXT NOT NULL, + label TEXT NOT NULL, + last_access INT NOT NULL, + last_origin TEXT NOT NULL, + expires INT NOT NULL, + 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; + ` + migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old` + migrate1To2InsertUserNoTx = ` + INSERT INTO user (id, user, pass, role, sync_topic, created) + SELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ? + ` + migrate1To2InsertFromOldTablesAndDropNoTx = ` + INSERT INTO user_access (user_id, topic, read, write) + SELECT u.id, a.topic, a.read, a.write + FROM user u + JOIN access a ON u.user = a.user; + + DROP TABLE access; + DROP TABLE user_old; + ` + + // 2 -> 3 + migrate2To3UpdateQueries = ` + ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT; + ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id; + ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT; + DROP INDEX IF EXISTS idx_tier_price_id; + CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); + CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); + ` + + // 3 -> 4 + migrate3To4UpdateQueries = ` + ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0); + ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0); + CREATE TABLE IF NOT EXISTS 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 + ); + ` + + // 4 -> 5 + migrate4To5UpdateQueries = ` + UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); + ` + + // 5 -> 6 + migrate5To6UpdateQueries = ` + PRAGMA foreign_keys=off; + + -- Alter user table: Add provisioned column + ALTER TABLE user RENAME TO user_old; + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + tier_id TEXT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', + sync_topic TEXT NOT NULL, + provisioned INT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), + stats_emails INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT, + stripe_subscription_interval TEXT, + stripe_subscription_paid_until INT, + stripe_subscription_cancel_at INT, + created INT NOT NULL, + deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + INSERT INTO user + SELECT + id, + tier_id, + user, + pass, + role, + prefs, + sync_topic, + 0, -- provisioned + stats_messages, + stats_emails, + stats_calls, + stripe_customer_id, + stripe_subscription_id, + stripe_subscription_status, + stripe_subscription_interval, + stripe_subscription_paid_until, + stripe_subscription_cancel_at, + created, + deleted + FROM user_old; + DROP TABLE user_old; + + -- Alter user_access table: Add provisioned column + ALTER TABLE user_access RENAME TO user_access_old; + CREATE TABLE user_access ( + user_id TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + owner_user_id INT, + provisioned INTEGER NOT NULL, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE + ); + INSERT INTO user_access SELECT *, 0 FROM user_access_old; + DROP TABLE user_access_old; + + -- Alter user_token table: Add provisioned column + ALTER TABLE user_token RENAME TO user_token_old; + CREATE TABLE IF NOT EXISTS user_token ( + user_id TEXT NOT NULL, + token TEXT NOT NULL, + label TEXT NOT NULL, + last_access INT NOT NULL, + last_origin TEXT NOT NULL, + expires INT NOT NULL, + provisioned INT NOT NULL, + PRIMARY KEY (user_id, token), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + INSERT INTO user_token SELECT *, 0 FROM user_token_old; + DROP TABLE user_token_old; + + -- Recreate indices + CREATE UNIQUE INDEX idx_user ON user (user); + 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; + ` +) + +var ( + migrations = map[int]func(db *sql.DB) error{ + 1: migrateFrom1, + 2: migrateFrom2, + 3: migrateFrom3, + 4: migrateFrom4, + 5: migrateFrom5, + } +) + +func migrateFrom1(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 1 to 2") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // Rename user -> user_old, and create new tables + if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil { + return err + } + // Insert users from user_old into new user table, with ID and sync_topic + rows, err := tx.Query(migrate1To2SelectAllOldUsernamesNoTx) + 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 + } + 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(migrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil { + return err + } + } + // Migrate old "access" table to "user_access" and drop "access" and "user_old" + if _, err := tx.Exec(migrate1To2InsertFromOldTablesAndDropNoTx); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 2); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +func migrateFrom2(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 2 to 3") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 3); err != nil { + return err + } + return tx.Commit() +} + +func migrateFrom3(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 3 to 4") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 4); err != nil { + return err + } + return tx.Commit() +} + +func migrateFrom4(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 4 to 5") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate4To5UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 5); err != nil { + return err + } + return tx.Commit() +} + +func migrateFrom5(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 5 to 6") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 6); err != nil { + return err + } + return tx.Commit() +} diff --git a/user/util.go b/user/util.go index 91230fae..170b7717 100644 --- a/user/util.go +++ b/user/util.go @@ -1,10 +1,12 @@ package user import ( - "golang.org/x/crypto/bcrypt" - "heckel.io/ntfy/v2/util" + "database/sql" "regexp" "strings" + + "golang.org/x/crypto/bcrypt" + "heckel.io/ntfy/v2/util" ) var ( @@ -77,3 +79,69 @@ func hashPassword(password string, cost int) (string, error) { } return string(hash), nil } + +func nullString(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + +func nullInt64(v int64) sql.NullInt64 { + if v == 0 { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: v, Valid: true} +} + +// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back. +func execTx(db *sql.DB, f func(tx *sql.Tx) error) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := f(tx); err != nil { + return err + } + return tx.Commit() +} + +// queryTx executes a function in a transaction and returns the result. If the function +// returns an error, the transaction is rolled back. +func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) { + tx, err := db.Begin() + if err != nil { + var zero T + return zero, err + } + defer tx.Rollback() + t, err := f(tx) + if err != nil { + return t, err + } + if err := tx.Commit(); err != nil { + return t, err + } + return t, nil +} + +// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards, +// and escapes '_', assuming '\' as escape character. +func toSQLWildcard(s string) string { + return escapeUnderscore(strings.ReplaceAll(s, "*", "%")) +} + +// fromSQLWildcard converts a SQL wildcard string to a wildcard string. It converts '%' to '*', +// and removes the '\_' escape character. +func fromSQLWildcard(s string) string { + return strings.ReplaceAll(unescapeUnderscore(s), "%", "*") +} + +func escapeUnderscore(s string) string { + return strings.ReplaceAll(s, "_", "\\_") +} + +func unescapeUnderscore(s string) string { + return strings.ReplaceAll(s, "\\_", "_") +} From 07c3e280bf670597dfb3cbc1f5ad678cef6faaf4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 22:39:54 -0500 Subject: [PATCH 190/548] Refactor user package to Store interface with PostgreSQL support Extract database operations from Manager into a Store interface with SQLite and PostgreSQL implementations using a shared commonStore. Split SQLite migrations into store_sqlite_migrations.go, use shared schema_version table for PostgreSQL, rename user_user/user_tier tables to "user"/tier, and wire up database-url in CLI commands. --- cmd/user.go | 25 +- server/server.go | 14 +- user/manager.go | 1193 ++--------------- user/manager_test.go | 89 +- user/store.go | 992 ++++++++++++++ user/store_postgres.go | 424 ++++++ user/store_sqlite.go | 420 ++++++ ...grations.go => store_sqlite_migrations.go} | 3 +- user/types.go | 14 + webpush/store_postgres.go | 16 +- 10 files changed, 2053 insertions(+), 1137 deletions(-) create mode 100644 user/store.go create mode 100644 user/store_postgres.go create mode 100644 user/store_sqlite.go rename user/{migrations.go => store_sqlite_migrations.go} (99%) diff --git a/cmd/user.go b/cmd/user.go index 6bf7030e..bf822970 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -29,6 +29,7 @@ var flagsUser = append( &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}), ) var cmdUser = &cli.Command{ @@ -365,24 +366,32 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") - if authFile == "" { - return nil, errors.New("option auth-file not set; auth is unconfigured for this server") - } else if !util.FileExists(authFile) { - return nil, errors.New("auth-file does not exist; please start the server at least once to create it") - } + databaseURL := c.String("database-url") authDefault, err := user.ParsePermission(authDefaultAccess) if err != nil { return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } authConfig := &user.Config{ - Filename: authFile, - StartupQueries: authStartupQueries, DefaultAccess: authDefault, ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, } - return user.NewManager(authConfig) + var store user.Store + if databaseURL != "" { + store, err = user.NewPostgresStore(databaseURL) + } else if authFile != "" { + if !util.FileExists(authFile) { + return nil, errors.New("auth-file does not exist; please start the server at least once to create it") + } + store, err = user.NewSQLiteStore(authFile, authStartupQueries) + } else { + return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server") + } + if err != nil { + return nil, err + } + return user.NewManager(store, authConfig) } func readPasswordAndConfirm(c *cli.Context) (string, error) { diff --git a/server/server.go b/server/server.go index de8af35f..ea03355b 100644 --- a/server/server.go +++ b/server/server.go @@ -204,9 +204,10 @@ func New(conf *Config) (*Server, error) { } } var userManager *user.Manager - if conf.AuthFile != "" { + if conf.AuthFile != "" || conf.DatabaseURL != "" { authConfig := &user.Config{ Filename: conf.AuthFile, + DatabaseURL: conf.DatabaseURL, StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, ProvisionEnabled: true, // Enable provisioning of users and access @@ -216,7 +217,16 @@ func New(conf *Config) (*Server, error) { BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } - userManager, err = user.NewManager(authConfig) + var store user.Store + if conf.DatabaseURL != "" { + store, err = user.NewPostgresStore(conf.DatabaseURL) + } else { + store, err = user.NewSQLiteStore(conf.AuthFile, conf.AuthStartupQueries) + } + if err != nil { + return nil, err + } + userManager, err = user.NewManager(store, authConfig) if err != nil { return nil, err } diff --git a/user/manager.go b/user/manager.go index 59aa883a..1e1b27f8 100644 --- a/user/manager.go +++ b/user/manager.go @@ -2,20 +2,15 @@ package user import ( - "database/sql" - "encoding/json" "errors" "fmt" "net/netip" - "path/filepath" "slices" "sync" "time" - "github.com/mattn/go-sqlite3" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/util" ) @@ -46,313 +41,19 @@ var ( errNoRows = errors.New("no rows found") ) -// Manager-related queries -const ( - createTablesQueries = ` - BEGIN; - CREATE TABLE IF NOT EXISTS tier ( - id TEXT PRIMARY KEY, - code TEXT NOT NULL, - name TEXT NOT NULL, - messages_limit INT NOT NULL, - messages_expiry_duration INT NOT NULL, - emails_limit INT NOT NULL, - calls_limit INT NOT NULL, - reservations_limit INT NOT NULL, - attachment_file_size_limit INT NOT NULL, - attachment_total_size_limit INT NOT NULL, - attachment_expiry_duration INT NOT NULL, - attachment_bandwidth_limit INT NOT NULL, - stripe_monthly_price_id TEXT, - stripe_yearly_price_id TEXT - ); - CREATE UNIQUE INDEX idx_tier_code ON tier (code); - CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); - CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); - CREATE TABLE IF NOT EXISTS user ( - id TEXT PRIMARY KEY, - tier_id TEXT, - user TEXT NOT NULL, - pass TEXT NOT NULL, - role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, - prefs JSON NOT NULL DEFAULT '{}', - sync_topic TEXT NOT NULL, - provisioned INT NOT NULL, - stats_messages INT NOT NULL DEFAULT (0), - stats_emails INT NOT NULL DEFAULT (0), - stats_calls INT NOT NULL DEFAULT (0), - stripe_customer_id TEXT, - stripe_subscription_id TEXT, - stripe_subscription_status TEXT, - stripe_subscription_interval TEXT, - stripe_subscription_paid_until INT, - stripe_subscription_cancel_at INT, - created INT NOT NULL, - deleted INT, - FOREIGN KEY (tier_id) REFERENCES tier (id) - ); - CREATE UNIQUE INDEX idx_user ON user (user); - 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 TABLE IF NOT EXISTS user_access ( - user_id TEXT NOT NULL, - topic TEXT NOT NULL, - read INT NOT NULL, - write INT NOT NULL, - owner_user_id INT, - provisioned INT NOT NULL, - PRIMARY KEY (user_id, topic), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, - FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS user_token ( - user_id TEXT NOT NULL, - token TEXT NOT NULL, - label TEXT NOT NULL, - last_access INT NOT NULL, - last_origin TEXT NOT NULL, - expires INT NOT NULL, - provisioned INT NOT NULL, - PRIMARY KEY (user_id, token), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX idx_user_token ON user_token (token); - CREATE TABLE IF NOT EXISTS 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 - ); - 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; - COMMIT; - ` - - builtinStartupQueries = ` - PRAGMA foreign_keys = ON; - ` - - selectUserByIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id - FROM user u - LEFT JOIN tier t on t.id = u.tier_id - WHERE u.id = ? - ` - selectUserByNameQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id - FROM user u - LEFT JOIN tier t on t.id = u.tier_id - WHERE user = ? - ` - selectUserByTokenQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id - FROM user u - JOIN user_token tk on u.id = tk.user_id - LEFT JOIN tier t on t.id = u.tier_id - WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) - ` - selectUserByStripeCustomerIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id - FROM user u - LEFT JOIN tier t on t.id = u.tier_id - WHERE u.stripe_customer_id = ? - ` - selectTopicPermsQuery = ` - SELECT read, write - FROM user_access a - JOIN user u ON u.id = a.user_id - WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\' - ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC - ` - - insertUserQuery = ` - INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) - VALUES (?, ?, ?, ?, ?, ?, ?) - ` - selectUsernamesQuery = ` - SELECT user - FROM user - ORDER BY - CASE role - WHEN 'admin' THEN 1 - WHEN 'anonymous' THEN 3 - ELSE 2 - END, user - ` - selectUserCountQuery = `SELECT COUNT(*) FROM user` - selectUserIDFromUsernameQuery = `SELECT id FROM user WHERE user = ?` - updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` - updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` - updateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?` - updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` - updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` - updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` - updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` - deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` - deleteUserQuery = `DELETE FROM user WHERE user = ?` - - upsertUserAccessQuery = ` - INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) - VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?) - ON CONFLICT (user_id, topic) - DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned - ` - selectUserAllAccessQuery = ` - SELECT user_id, topic, read, write, provisioned - FROM user_access - ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic - ` - selectUserAccessQuery = ` - SELECT topic, read, write, provisioned - FROM user_access - WHERE user_id = (SELECT id FROM user WHERE user = ?) - ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic - ` - selectUserReservationsQuery = ` - SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write - FROM user_access a_user - LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?) - WHERE a_user.user_id = a_user.owner_user_id - AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?) - ORDER BY a_user.topic - ` - selectUserReservationsCountQuery = ` - SELECT COUNT(*) - FROM user_access - WHERE user_id = owner_user_id - AND owner_user_id = (SELECT id FROM user WHERE user = ?) - ` - selectUserReservationsOwnerQuery = ` - SELECT owner_user_id - FROM user_access - WHERE topic = ? - AND user_id = owner_user_id - ` - selectUserHasReservationQuery = ` - SELECT COUNT(*) - FROM user_access - WHERE user_id = owner_user_id - AND owner_user_id = (SELECT id FROM user WHERE user = ?) - AND topic = ? - ` - selectOtherAccessCountQuery = ` - SELECT COUNT(*) - FROM user_access - WHERE (topic = ? OR ? LIKE topic ESCAPE '\') - AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) - ` - deleteAllAccessQuery = `DELETE FROM user_access` - deleteUserAccessQuery = ` - DELETE FROM user_access - WHERE user_id = (SELECT id FROM user WHERE user = ?) - OR owner_user_id = (SELECT id FROM user WHERE user = ?) - ` - deleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = 1` - deleteTopicAccessQuery = ` - DELETE FROM user_access - WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)) - AND topic = ? - ` - - selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` - selectTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ?` - selectTokenQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?` - selectAllProvisionedTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1` - upsertTokenQuery = ` - INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (user_id, token) - DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned; - ` - updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` - updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` - updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` - deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` - deleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = ?` - deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` - deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` - deleteExcessTokensQuery = ` - DELETE FROM user_token - WHERE user_id = ? - AND (user_id, token) NOT IN ( - SELECT user_id, token - FROM user_token - WHERE user_id = ? - ORDER BY expires DESC - LIMIT ? - ) - ` - - selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?` - insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` - deletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` - - insertTierQuery = ` - INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - updateTierQuery = ` - UPDATE tier - SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? - WHERE code = ? - ` - selectTiersQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id - FROM tier - ` - selectTierByCodeQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id - FROM tier - WHERE code = ? - ` - selectTierByPriceIDQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id - FROM tier - WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) - ` - updateUserTierQuery = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?` - deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?` - deleteTierQuery = `DELETE FROM tier WHERE code = ?` - - updateBillingQuery = ` - UPDATE user - SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ? - WHERE user = ? - ` -) - -// Manager is an implementation of Manager. It stores users and access control list -// in a SQLite database. +// Manager handles user authentication, authorization, and management type Manager struct { config *Config - db *sql.DB + store Store // Database store statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) mu sync.Mutex } -// Config holds the configuration for the user Manager -type Config struct { - Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" - StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers - DefaultAccess Permission // Default permission if no ACL matches - ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands - Users []*User // Predefined users to create on startup - Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant) - Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) - QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database - BcryptCost int // Cost of generated passwords; lowering makes testing faster -} - var _ Auther = (*Manager)(nil) // NewManager creates a new Manager instance -func NewManager(config *Config) (*Manager, error) { +func NewManager(store Store, config *Config) (*Manager, error) { // Set defaults if config.BcryptCost <= 0 { config.BcryptCost = DefaultUserPasswordBcryptCost @@ -360,24 +61,8 @@ func NewManager(config *Config) (*Manager, error) { if config.QueueWriterInterval.Seconds() <= 0 { config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } - // Check the parent directory of the database file (makes for friendly error messages) - parentDir := filepath.Dir(config.Filename) - if !util.FileExists(parentDir) { - return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir) - } - // Open DB and run setup queries - db, err := sql.Open("sqlite3", config.Filename) - if err != nil { - return nil, err - } - if err := setupDB(db); err != nil { - return nil, err - } - if err := runStartupQueries(db, config.StartupQueries); err != nil { - return nil, err - } manager := &Manager{ - db: db, + store: store, config: config, statsQueue: make(map[string]*Stats), tokenQueue: make(map[string]*TokenUpdate), @@ -396,7 +81,7 @@ func (a *Manager) Authenticate(username, password string) (*User, error) { if username == Everyone { return nil, ErrUnauthenticated } - user, err := a.User(username) + user, err := a.store.User(username) if err != nil { log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)") bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) @@ -418,7 +103,7 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { if len(token) != tokenLength { return nil, ErrUnauthenticated } - user, err := a.userByToken(token) + user, err := a.store.UserByToken(token) if err != nil { log.Tag(tag).Field("token", token).Err(err).Trace("Authentication of token failed") return nil, ErrUnauthenticated @@ -431,118 +116,34 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { // after a fixed duration unless ChangeToken is called. This function also prunes tokens for the // given user, if there are too many of them. func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) { - return queryTx(a.db, func(tx *sql.Tx) (*Token, error) { - return a.createTokenTx(tx, userID, GenerateToken(), label, expires, origin, provisioned) - }) -} - -func (a *Manager) createTokenTx(tx *sql.Tx, userID, token, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) { + token := GenerateToken() access := time.Now() - if _, err := tx.Exec(upsertTokenQuery, userID, token, label, access.Unix(), origin.String(), expires.Unix(), provisioned); err != nil { - return nil, err - } - rows, err := tx.Query(selectTokenCountQuery, userID) + // Create the token + createdToken, err := a.store.CreateToken(userID, token, label, access, origin, expires, provisioned) if err != nil { return nil, err } - defer rows.Close() - if !rows.Next() { - return nil, errNoRows - } - var tokenCount int - if err := rows.Scan(&tokenCount); err != nil { + // Check token count and prune if necessary + tokenCount, err := a.store.TokenCount(userID) + if err != nil { return nil, err } if tokenCount >= tokenMaxCount { - // This pruning logic is done in two queries for efficiency. The SELECT above is a lookup - // on two indices, whereas the query below is a full table scan. - if _, err := tx.Exec(deleteExcessTokensQuery, userID, userID, tokenMaxCount); err != nil { + if err := a.store.RemoveExcessTokens(userID, tokenMaxCount); err != nil { return nil, err } } - return &Token{ - Value: token, - Label: label, - LastAccess: access, - LastOrigin: origin, - Expires: expires, - Provisioned: provisioned, - }, nil + return createdToken, nil } // Tokens returns all existing tokens for the user with the given user ID func (a *Manager) Tokens(userID string) ([]*Token, error) { - rows, err := a.db.Query(selectTokensQuery, userID) - if err != nil { - return nil, err - } - defer rows.Close() - tokens := make([]*Token, 0) - for { - token, err := a.readToken(rows) - if errors.Is(err, ErrTokenNotFound) { - break - } else if err != nil { - return nil, err - } - tokens = append(tokens, token) - } - return tokens, nil -} - -func (a *Manager) allProvisionedTokens() ([]*Token, error) { - rows, err := a.db.Query(selectAllProvisionedTokensQuery) - if err != nil { - return nil, err - } - defer rows.Close() - tokens := make([]*Token, 0) - for { - token, err := a.readToken(rows) - if errors.Is(err, ErrTokenNotFound) { - break - } else if err != nil { - return nil, err - } - tokens = append(tokens, token) - } - return tokens, nil + return a.store.Tokens(userID) } // Token returns a specific token for a user func (a *Manager) Token(userID, token string) (*Token, error) { - rows, err := a.db.Query(selectTokenQuery, userID, token) - if err != nil { - return nil, err - } - defer rows.Close() - return a.readToken(rows) -} - -func (a *Manager) readToken(rows *sql.Rows) (*Token, error) { - var token, label, lastOrigin string - var lastAccess, expires int64 - var provisioned bool - if !rows.Next() { - return nil, ErrTokenNotFound - } - if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - lastOriginIP, err := netip.ParseAddr(lastOrigin) - if err != nil { - lastOriginIP = netip.IPv4Unspecified() - } - return &Token{ - Value: token, - Label: label, - LastAccess: time.Unix(lastAccess, 0), - LastOrigin: lastOriginIP, - Expires: time.Unix(expires, 0), - Provisioned: provisioned, - }, nil + return a.store.Token(userID, token) } // ChangeToken updates a token's label and/or expiry date @@ -553,24 +154,16 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time if err := a.CanChangeToken(userID, token); err != nil { return nil, err } - tx, err := a.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() if label != nil { - if _, err := tx.Exec(updateTokenLabelQuery, *label, userID, token); err != nil { + if err := a.store.ChangeTokenLabel(userID, token, *label); err != nil { return nil, err } } if expires != nil { - if _, err := tx.Exec(updateTokenExpiryQuery, expires.Unix(), userID, token); err != nil { + if err := a.store.ChangeTokenExpiry(userID, token, *expires); err != nil { return nil, err } } - if err := tx.Commit(); err != nil { - return nil, err - } return a.Token(userID, token) } @@ -579,19 +172,7 @@ func (a *Manager) RemoveToken(userID, token string) error { if err := a.CanChangeToken(userID, token); err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { - return a.removeTokenTx(tx, userID, token) - }) -} - -func (a *Manager) removeTokenTx(tx *sql.Tx, userID, token string) error { - if token == "" { - return errNoTokenProvided - } - if _, err := tx.Exec(deleteTokenQuery, userID, token); err != nil { - return err - } - return nil + return a.store.RemoveToken(userID, token) } // CanChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed. @@ -607,87 +188,39 @@ func (a *Manager) CanChangeToken(userID, token string) error { // RemoveExpiredTokens deletes all expired tokens from the database func (a *Manager) RemoveExpiredTokens() error { - if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil { - return err - } - return nil + return a.store.RemoveExpiredTokens() } // PhoneNumbers returns all phone numbers for the user with the given user ID func (a *Manager) PhoneNumbers(userID string) ([]string, error) { - rows, err := a.db.Query(selectPhoneNumbersQuery, userID) - if err != nil { - return nil, err - } - defer rows.Close() - phoneNumbers := make([]string, 0) - for { - phoneNumber, err := a.readPhoneNumber(rows) - if errors.Is(err, ErrPhoneNumberNotFound) { - break - } else if err != nil { - return nil, err - } - phoneNumbers = append(phoneNumbers, phoneNumber) - } - return phoneNumbers, nil -} - -func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { - var phoneNumber string - if !rows.Next() { - return "", ErrPhoneNumberNotFound - } - if err := rows.Scan(&phoneNumber); err != nil { - return "", err - } else if err := rows.Err(); err != nil { - return "", err - } - return phoneNumber, nil + return a.store.PhoneNumbers(userID) } // AddPhoneNumber adds a phone number to the user with the given user ID func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { - if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil { - if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { - return ErrPhoneNumberExists - } - return err - } - return nil + return a.store.AddPhoneNumber(userID, phoneNumber) } // RemovePhoneNumber deletes a phone number from the user with the given user ID func (a *Manager) RemovePhoneNumber(userID string, phoneNumber string) error { - _, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber) - return err + return a.store.RemovePhoneNumber(userID, phoneNumber) } -// RemoveDeletedUsers deletes all users that have been marked deleted for +// RemoveDeletedUsers deletes all users that have been marked deleted func (a *Manager) RemoveDeletedUsers() error { - if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil { - return err - } - return nil + return a.store.RemoveDeletedUsers() } // ChangeSettings persists the user settings func (a *Manager) ChangeSettings(userID string, prefs *Prefs) error { - b, err := json.Marshal(prefs) - if err != nil { - return err - } - if _, err := a.db.Exec(updateUserPrefsQuery, string(b), userID); err != nil { - return err - } - return nil + return a.store.ChangeSettings(userID, prefs) } // ResetStats resets all user stats in the user database. This touches all users. func (a *Manager) ResetStats() error { a.mu.Lock() // Includes database query to avoid races! defer a.mu.Unlock() - if _, err := a.db.Exec(updateUserStatsResetAllQuery); err != nil { + if err := a.store.ResetStats(); err != nil { return err } a.statsQueue = make(map[string]*Stats) @@ -702,7 +235,7 @@ func (a *Manager) EnqueueUserStats(userID string, stats *Stats) { a.statsQueue[userID] = stats } -// EnqueueTokenUpdate adds the token update to a queue which writes out token access times +// EnqueueTokenUpdate adds the token update to a queue which writes out token access times // in batches at a regular interval func (a *Manager) EnqueueTokenUpdate(tokenID string, update *TokenUpdate) { a.mu.Lock() @@ -732,11 +265,7 @@ func (a *Manager) writeUserStatsQueue() error { statsQueue := a.statsQueue a.statsQueue = make(map[string]*Stats) a.mu.Unlock() - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() + log.Tag(tag).Debug("Writing user stats queue for %d user(s)", len(statsQueue)) for userID, update := range statsQueue { log. @@ -748,11 +277,11 @@ func (a *Manager) writeUserStatsQueue() error { "calls_count": update.Calls, }). Trace("Updating stats for user %s", userID) - if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil { + if err := a.store.UpdateStats(userID, update); err != nil { return err } } - return tx.Commit() + return nil } func (a *Manager) writeTokenUpdateQueue() error { @@ -765,25 +294,14 @@ func (a *Manager) writeTokenUpdateQueue() error { tokenQueue := a.tokenQueue a.tokenQueue = make(map[string]*TokenUpdate) a.mu.Unlock() - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() + log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) for tokenID, update := range tokenQueue { log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) - if err := a.updateTokenLastAccessTx(tx, tokenID, update.LastAccess.Unix(), update.LastOrigin.String()); err != nil { + if err := a.store.UpdateTokenLastAccess(tokenID, update.LastAccess, update.LastOrigin); err != nil { return err } } - return tx.Commit() -} - -func (a *Manager) updateTokenLastAccessTx(tx *sql.Tx, token string, lastAccess int64, lastOrigin string) error { - if _, err := tx.Exec(updateTokenLastAccessQuery, lastAccess, lastOrigin, token); err != nil { - return err - } return nil } @@ -798,23 +316,13 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { username = user.Name } // Select the read/write permissions for this user/topic combo. - // - The query may return two rows (one for everyone, and one for the user), but prioritizes the user. - // - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" - // - It also prioritizes write permissions over read permissions - rows, err := a.db.Query(selectTopicPermsQuery, Everyone, username, topic) + read, write, found, err := a.store.AuthorizeTopicAccess(username, topic) if err != nil { return err } - defer rows.Close() - if !rows.Next() { + if !found { return a.resolvePerms(a.config.DefaultAccess, perm) } - var read, write bool - if err := rows.Scan(&read, &write); err != nil { - return err - } else if err := rows.Err(); err != nil { - return err - } return a.resolvePerms(NewPermission(read, write), perm) } @@ -829,18 +337,15 @@ func (a *Manager) resolvePerms(base, perm Permission) error { // AddUser adds a user with the given username, password and role func (a *Manager) AddUser(username, password string, role Role, hashed bool) error { - return execTx(a.db, func(tx *sql.Tx) error { - return a.addUserTx(tx, username, password, role, hashed, false) - }) + return a.addUser(username, password, role, hashed, false) } -// AddUser adds a user with the given username, password and role -func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, hashed, provisioned bool) error { +func (a *Manager) addUser(username, password string, role Role, hashed, provisioned bool) error { if !AllowedUsername(username) || !AllowedRole(role) { return ErrInvalidArgument } var hash string - var err error = nil + var err error if hashed { hash = password if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { @@ -852,15 +357,7 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha return err } } - userID := util.RandomStringPrefix(userIDPrefix, userIDLength) - syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix() - if _, err = tx.Exec(insertUserQuery, userID, username, hash, role, syncTopic, provisioned, now); err != nil { - if errors.Is(err, sqlite3.ErrConstraintUnique) { - return ErrUserExists - } - return err - } - return nil + return a.store.AddUser(username, hash, role, provisioned) } // RemoveUser deletes the user with the given username. The function returns nil on success, even @@ -869,20 +366,7 @@ func (a *Manager) RemoveUser(username string) error { if err := a.CanChangeUser(username); err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { - return a.removeUserTx(tx, username) - }) -} - -func (a *Manager) removeUserTx(tx *sql.Tx, username string) error { - if !AllowedUsername(username) { - return ErrInvalidArgument - } - // Rows in user_access, user_token, etc. are deleted via foreign keys - if _, err := tx.Exec(deleteUserQuery, username); err != nil { - return err - } - return nil + return a.store.RemoveUser(username) } // MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens. This prevents @@ -891,297 +375,64 @@ func (a *Manager) MarkUserRemoved(user *User) error { if !AllowedUsername(user.Name) { return ErrInvalidArgument } - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(deleteUserAccessQuery, user.Name, user.Name); err != nil { - return err - } - if _, err := tx.Exec(deleteAllTokenQuery, user.ID); err != nil { - return err - } - if _, err := tx.Exec(updateUserDeletedQuery, time.Now().Add(userHardDeleteAfterDuration).Unix(), user.ID); err != nil { - return err - } - return tx.Commit() + return a.store.MarkUserRemoved(user.ID) } // Users returns a list of users. It always also returns the Everyone user ("*"). func (a *Manager) Users() ([]*User, error) { - rows, err := a.db.Query(selectUsernamesQuery) - if err != nil { - return nil, err - } - defer rows.Close() - usernames := make([]string, 0) - for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - usernames = append(usernames, username) - } - rows.Close() - users := make([]*User, 0) - for _, username := range usernames { - user, err := a.User(username) - if err != nil { - return nil, err - } - users = append(users, user) - } - return users, nil + return a.store.Users() } -// UsersCount returns the number of users in the databsae +// UsersCount returns the number of users in the database func (a *Manager) UsersCount() (int64, error) { - rows, err := a.db.Query(selectUserCountQuery) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - var count int64 - if err := rows.Scan(&count); err != nil { - return 0, err - } - return count, nil + return a.store.UsersCount() } // User returns the user with the given username if it exists, or ErrUserNotFound otherwise. // You may also pass Everyone to retrieve the anonymous user and its Grant list. func (a *Manager) User(username string) (*User, error) { - rows, err := a.db.Query(selectUserByNameQuery, username) - if err != nil { - return nil, err - } - return a.readUser(rows) + return a.store.User(username) } // UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise func (a *Manager) UserByID(id string) (*User, error) { - rows, err := a.db.Query(selectUserByIDQuery, id) - if err != nil { - return nil, err - } - return a.readUser(rows) + return a.store.UserByID(id) } // UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise. func (a *Manager) UserByStripeCustomer(stripeCustomerID string) (*User, error) { - rows, err := a.db.Query(selectUserByStripeCustomerIDQuery, stripeCustomerID) - if err != nil { - return nil, err - } - return a.readUser(rows) -} - -func (a *Manager) userByToken(token string) (*User, error) { - rows, err := a.db.Query(selectUserByTokenQuery, token, time.Now().Unix()) - if err != nil { - return nil, err - } - return a.readUser(rows) -} - -func (a *Manager) readUser(rows *sql.Rows) (*User, error) { - defer rows.Close() - var id, username, hash, role, prefs, syncTopic string - var provisioned bool - var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString - var messages, emails, calls int64 - var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 - if !rows.Next() { - return nil, ErrUserNotFound - } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - user := &User{ - ID: id, - Name: username, - Hash: hash, - Role: Role(role), - Prefs: &Prefs{}, - SyncTopic: syncTopic, - Provisioned: provisioned, - Stats: &Stats{ - Messages: messages, - Emails: emails, - Calls: calls, - }, - Billing: &Billing{ - StripeCustomerID: stripeCustomerID.String, // May be empty - StripeSubscriptionID: stripeSubscriptionID.String, // May be empty - StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty - StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty - StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero - StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero - }, - Deleted: deleted.Valid, - } - if err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil { - return nil, err - } - if tierCode.Valid { - // See readTier() when this is changed! - user.Tier = &Tier{ - ID: tierID.String, - Code: tierCode.String, - Name: tierName.String, - MessageLimit: messagesLimit.Int64, - MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, - EmailLimit: emailsLimit.Int64, - CallLimit: callsLimit.Int64, - ReservationLimit: reservationsLimit.Int64, - AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, - AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, - AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, - AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, - StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty - StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty - } - } - return user, nil + return a.store.UserByStripeCustomer(stripeCustomerID) } // AllGrants returns all user-specific access control entries, mapped to their respective user IDs func (a *Manager) AllGrants() (map[string][]Grant, error) { - rows, err := a.db.Query(selectUserAllAccessQuery) - if err != nil { - return nil, err - } - defer rows.Close() - grants := make(map[string][]Grant, 0) - for rows.Next() { - var userID, topic string - var read, write, provisioned bool - if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - if _, ok := grants[userID]; !ok { - grants[userID] = make([]Grant, 0) - } - grants[userID] = append(grants[userID], Grant{ - TopicPattern: fromSQLWildcard(topic), - Permission: NewPermission(read, write), - Provisioned: provisioned, - }) - } - return grants, nil + return a.store.AllGrants() } // Grants returns all user-specific access control entries func (a *Manager) Grants(username string) ([]Grant, error) { - rows, err := a.db.Query(selectUserAccessQuery, username) - if err != nil { - return nil, err - } - defer rows.Close() - grants := make([]Grant, 0) - for rows.Next() { - var topic string - var read, write, provisioned bool - if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - grants = append(grants, Grant{ - TopicPattern: fromSQLWildcard(topic), - Permission: NewPermission(read, write), - Provisioned: provisioned, - }) - } - return grants, nil + return a.store.Grants(username) } // Reservations returns all user-owned topics, and the associated everyone-access func (a *Manager) Reservations(username string) ([]Reservation, error) { - rows, err := a.db.Query(selectUserReservationsQuery, Everyone, username) - if err != nil { - return nil, err - } - defer rows.Close() - reservations := make([]Reservation, 0) - for rows.Next() { - var topic string - var ownerRead, ownerWrite bool - var everyoneRead, everyoneWrite sql.NullBool - if err := rows.Scan(&topic, &ownerRead, &ownerWrite, &everyoneRead, &everyoneWrite); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - reservations = append(reservations, Reservation{ - Topic: unescapeUnderscore(topic), - Owner: NewPermission(ownerRead, ownerWrite), - Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool), // false if null - }) - } - return reservations, nil + return a.store.Reservations(username) } // HasReservation returns true if the given topic access is owned by the user func (a *Manager) HasReservation(username, topic string) (bool, error) { - rows, err := a.db.Query(selectUserHasReservationQuery, username, escapeUnderscore(topic)) - if err != nil { - return false, err - } - defer rows.Close() - if !rows.Next() { - return false, errNoRows - } - var count int64 - if err := rows.Scan(&count); err != nil { - return false, err - } - return count > 0, nil + return a.store.HasReservation(username, topic) } // ReservationsCount returns the number of reservations owned by this user func (a *Manager) ReservationsCount(username string) (int64, error) { - rows, err := a.db.Query(selectUserReservationsCountQuery, username) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - var count int64 - if err := rows.Scan(&count); err != nil { - return 0, err - } - return count, nil + return a.store.ReservationsCount(username) } // ReservationOwner returns user ID of the user that owns this topic, or an // empty string if it's not owned by anyone func (a *Manager) ReservationOwner(topic string) (string, error) { - rows, err := a.db.Query(selectUserReservationsOwnerQuery, escapeUnderscore(topic)) - if err != nil { - return "", err - } - defer rows.Close() - if !rows.Next() { - return "", nil - } - var ownerUserID string - if err := rows.Scan(&ownerUserID); err != nil { - return "", err - } - return ownerUserID, nil + return a.store.ReservationOwner(topic) } // ChangePassword changes a user's password @@ -1189,9 +440,20 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { if err := a.CanChangeUser(username); err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { - return a.changePasswordTx(tx, username, password, hashed) - }) + var hash string + var err error + if hashed { + hash = password + if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { + return err + } + } else { + hash, err = hashPassword(password, a.config.BcryptCost) + if err != nil { + return err + } + } + return a.store.ChangePassword(username, hash) } // CanChangeUser checks if the user with the given username can be changed. @@ -1206,59 +468,13 @@ func (a *Manager) CanChangeUser(username string) error { return nil } -func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error { - var hash string - var err error - if hashed { - hash = password - if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { - return err - } - } else { - hash, err = hashPassword(password, a.config.BcryptCost) - if err != nil { - return err - } - } - if _, err := tx.Exec(updateUserPassQuery, hash, username); err != nil { - return err - } - return nil -} - // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, // all existing access control entries (Grant) are removed, since they are no longer needed. func (a *Manager) ChangeRole(username string, role Role) error { if err := a.CanChangeUser(username); err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { - return a.changeRoleTx(tx, username, role) - }) -} - -func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - if _, err := tx.Exec(updateUserRoleQuery, string(role), username); err != nil { - return err - } - if role == RoleAdmin { - if _, err := tx.Exec(deleteUserAccessQuery, username, username); err != nil { - return err - } - } - return nil -} - -// changeProvisionedTx changes the provisioned status of a user. This is used to mark users as -// provisioned. A provisioned user is a user defined in the config file. -func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error { - if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil { - return err - } - return nil + return a.store.ChangeRole(username, role) } // ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, @@ -1273,10 +489,7 @@ func (a *Manager) ChangeTier(username, tier string) error { } else if err := a.checkReservationsLimit(username, t.ReservationLimit); err != nil { return err } - if _, err := a.db.Exec(updateUserTierQuery, tier, username); err != nil { - return err - } - return nil + return a.store.ChangeTier(username, tier) } // ResetTier removes the tier from the given user @@ -1286,8 +499,7 @@ func (a *Manager) ResetTier(username string) error { } else if err := a.checkReservationsLimit(username, 0); err != nil { return err } - _, err := a.db.Exec(deleteUserTierQuery, username) - return err + return a.store.ResetTier(username) } func (a *Manager) checkReservationsLimit(username string, reservationsLimit int64) error { @@ -1312,69 +524,45 @@ func (a *Manager) AllowReservation(username string, topic string) error { if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { return ErrInvalidArgument } - rows, err := a.db.Query(selectOtherAccessCountQuery, escapeUnderscore(topic), escapeUnderscore(topic), username) + otherCount, err := a.store.OtherAccessCount(username, topic) if err != nil { return err } - defer rows.Close() - if !rows.Next() { - return errNoRows - } - var otherCount int - if err := rows.Scan(&otherCount); err != nil { - return err - } if otherCount > 0 { return errTopicOwnedByOthers } return nil } -// AllowAccess adds or updates an entry in th access control list for a specific user. It controls +// AllowAccess adds or updates an entry in the access control list for a specific user. It controls // read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry // owner may either be a user (username), or the system (empty). func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { - return execTx(a.db, func(tx *sql.Tx) error { - return a.allowAccessTx(tx, username, topicPattern, permission, false) - }) + return a.allowAccess(username, topicPattern, permission, false) } -func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error { +func (a *Manager) allowAccess(username string, topicPattern string, permission Permission, provisioned bool) error { if !AllowedUsername(username) && username != Everyone { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) { return ErrInvalidArgument } - owner := "" - if _, err := tx.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner, provisioned); err != nil { - return err - } - return nil + return a.store.AllowAccess(username, topicPattern, permission.IsRead(), permission.IsWrite(), "", provisioned) } // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is // empty) for an entire user. The parameter topicPattern may include wildcards (*). func (a *Manager) ResetAccess(username string, topicPattern string) error { - return execTx(a.db, func(tx *sql.Tx) error { - return a.resetAccessTx(tx, username, topicPattern) - }) + return a.resetAccess(username, topicPattern) } -func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error { +func (a *Manager) resetAccess(username string, topicPattern string) error { if !AllowedUsername(username) && username != Everyone && username != "" { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { return ErrInvalidArgument } - if username == "" && topicPattern == "" { - _, err := tx.Exec(deleteAllAccessQuery, username) - return err - } else if topicPattern == "" { - _, err := tx.Exec(deleteUserAccessQuery, username, username) - return err - } - _, err := tx.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern)) - return err + return a.store.ResetAccess(username, topicPattern) } // AddReservation creates two access control entries for the given topic: one with full read/write access for the @@ -1384,18 +572,13 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { return ErrInvalidArgument } - tx, err := a.db.Begin() - if err != nil { + if err := a.store.AllowAccess(username, topic, true, true, username, false); err != nil { return err } - defer tx.Rollback() - if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username, false); err != nil { + if err := a.store.AllowAccess(Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username, false); err != nil { return err } - if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil { - return err - } - return tx.Commit() + return nil } // RemoveReservations deletes the access control entries associated with the given username/topic, as @@ -1409,20 +592,15 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { return ErrInvalidArgument } } - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() for _, topic := range topics { - if _, err := tx.Exec(deleteTopicAccessQuery, username, username, escapeUnderscore(topic)); err != nil { + if err := a.store.ResetAccess(username, topic); err != nil { return err } - if _, err := tx.Exec(deleteTopicAccessQuery, Everyone, Everyone, escapeUnderscore(topic)); err != nil { + if err := a.store.ResetAccess(Everyone, topic); err != nil { return err } } - return tx.Commit() + return nil } // DefaultAccess returns the default read/write access if no access control entry matches @@ -1432,117 +610,42 @@ func (a *Manager) DefaultAccess() Permission { // AddTier creates a new tier in the database func (a *Manager) AddTier(tier *Tier) error { - if tier.ID == "" { - tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) - } - if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { - return err - } - return nil + return a.store.AddTier(tier) } // UpdateTier updates a tier's properties in the database func (a *Manager) UpdateTier(tier *Tier) error { - if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { - return err - } - return nil + return a.store.UpdateTier(tier) } // RemoveTier deletes the tier with the given code func (a *Manager) RemoveTier(code string) error { - if !AllowedTier(code) { - return ErrInvalidArgument - } - // This fails if any user has this tier - if _, err := a.db.Exec(deleteTierQuery, code); err != nil { - return err - } - return nil + return a.store.RemoveTier(code) } // ChangeBilling updates a user's billing fields, namely the Stripe customer ID, and subscription information func (a *Manager) ChangeBilling(username string, billing *Billing) error { - if _, err := a.db.Exec(updateBillingQuery, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { - return err - } - return nil + return a.store.ChangeBilling(username, billing) } // Tiers returns a list of all Tier structs func (a *Manager) Tiers() ([]*Tier, error) { - rows, err := a.db.Query(selectTiersQuery) - if err != nil { - return nil, err - } - defer rows.Close() - tiers := make([]*Tier, 0) - for { - tier, err := a.readTier(rows) - if errors.Is(err, ErrTierNotFound) { - break - } else if err != nil { - return nil, err - } - tiers = append(tiers, tier) - } - return tiers, nil + return a.store.Tiers() } // Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist func (a *Manager) Tier(code string) (*Tier, error) { - rows, err := a.db.Query(selectTierByCodeQuery, code) - if err != nil { - return nil, err - } - defer rows.Close() - return a.readTier(rows) + return a.store.Tier(code) } // TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { - rows, err := a.db.Query(selectTierByPriceIDQuery, priceID, priceID) - if err != nil { - return nil, err - } - defer rows.Close() - return a.readTier(rows) -} - -func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { - var id, code, name string - var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString - var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 - if !rows.Next() { - return nil, ErrTierNotFound - } - if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - // When changed, note readUser() as well - return &Tier{ - ID: id, - Code: code, - Name: name, - MessageLimit: messagesLimit.Int64, - MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, - EmailLimit: emailsLimit.Int64, - CallLimit: callsLimit.Int64, - ReservationLimit: reservationsLimit.Int64, - AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, - AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, - AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, - AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, - StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty - StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty - }, nil + return a.store.TierByStripePrice(priceID) } // Close closes the underlying database func (a *Manager) Close() error { - return a.db.Close() + return a.store.Close() } // maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config. @@ -1557,29 +660,27 @@ func (a *Manager) maybeProvisionUsersAccessAndTokens() error { provisionUsernames := util.Map(a.config.Users, func(u *User) string { return u.Name }) - return execTx(a.db, func(tx *sql.Tx) error { - if err := a.maybeProvisionUsers(tx, provisionUsernames, existingUsers); err != nil { - return fmt.Errorf("failed to provision users: %v", err) - } - if err := a.maybeProvisionGrants(tx); err != nil { - return fmt.Errorf("failed to provision grants: %v", err) - } - if err := a.maybeProvisionTokens(tx, provisionUsernames); err != nil { - return fmt.Errorf("failed to provision tokens: %v", err) - } - return nil - }) + if err := a.maybeProvisionUsers(provisionUsernames, existingUsers); err != nil { + return fmt.Errorf("failed to provision users: %v", err) + } + if err := a.maybeProvisionGrants(); err != nil { + return fmt.Errorf("failed to provision grants: %v", err) + } + if err := a.maybeProvisionTokens(provisionUsernames); err != nil { + return fmt.Errorf("failed to provision tokens: %v", err) + } + return nil } // maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them. // It also removes users that are provisioned, but not in the config anymore. -func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, existingUsers []*User) error { +func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers []*User) error { // Remove users that are provisioned, but not in the config anymore for _, user := range existingUsers { if user.Name == Everyone { continue } else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) { - if err := a.removeUserTx(tx, user.Name); err != nil { + if err := a.store.RemoveUser(user.Name); err != nil { return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err) } } @@ -1593,22 +694,22 @@ func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, e return u.Name == user.Name }) if !exists { - if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { + if err := a.addUser(user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) } } else { if !existingUser.Provisioned { - if err := a.changeProvisionedTx(tx, user.Name, true); err != nil { + if err := a.store.ChangeProvisioned(user.Name, true); err != nil { return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err) } } if existingUser.Hash != user.Hash { - if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil { + if err := a.store.ChangePassword(user.Name, user.Hash); err != nil { return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) } } if existingUser.Role != user.Role { - if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil { + if err := a.store.ChangeRole(user.Name, user.Role); err != nil { return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) } } @@ -1621,9 +722,9 @@ func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, e // // Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last // access time) or do not have dependent resources (such as grants or tokens). -func (a *Manager) maybeProvisionGrants(tx *sql.Tx) error { +func (a *Manager) maybeProvisionGrants() error { // Remove all provisioned grants - if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil { + if err := a.store.ResetAllProvisionedAccess(); err != nil { return err } // (Re-)add provisioned grants @@ -1637,10 +738,10 @@ func (a *Manager) maybeProvisionGrants(tx *sql.Tx) error { return fmt.Errorf("adding access control entries is not allowed for admin roles for user %s", username) } for _, grant := range grants { - if err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil { + if err := a.resetAccess(username, grant.TopicPattern); err != nil { return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err) } - if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil { + if err := a.allowAccess(username, grant.TopicPattern, grant.Permission, true); err != nil { return err } } @@ -1648,9 +749,9 @@ func (a *Manager) maybeProvisionGrants(tx *sql.Tx) error { return nil } -func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string) error { +func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { // Remove tokens that are provisioned, but not in the config anymore - existingTokens, err := a.allProvisionedTokens() + existingTokens, err := a.store.AllProvisionedTokens() if err != nil { return fmt.Errorf("failed to retrieve existing provisioned tokens: %v", err) } @@ -1662,7 +763,7 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string) } for _, existingToken := range existingTokens { if !slices.Contains(provisionTokens, existingToken.Value) { - if _, err := tx.Exec(deleteProvisionedTokenQuery, existingToken.Value); err != nil { + if err := a.store.RemoveToken("", existingToken.Value); err != nil { return fmt.Errorf("failed to remove provisioned token %s: %v", existingToken.Value, err) } } @@ -1672,71 +773,15 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string) if !slices.Contains(provisionUsernames, username) && username != Everyone { return fmt.Errorf("user %s is not a provisioned user, refusing to add tokens", username) } - var userID string - row := tx.QueryRow(selectUserIDFromUsernameQuery, username) - if err := row.Scan(&userID); err != nil { - return fmt.Errorf("failed to find provisioned user %s for provisioned tokens", username) + userID, err := a.store.UserIDByUsername(username) + if err != nil { + return fmt.Errorf("failed to find provisioned user %s for provisioned tokens: %v", username, err) } for _, token := range tokens { - if _, err := a.createTokenTx(tx, userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), true); err != nil { + if _, err := a.store.CreateToken(userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), true); err != nil { return err } } } return nil } - -func runStartupQueries(db *sql.DB, startupQueries string) error { - if _, err := db.Exec(startupQueries); err != nil { - return err - } - if _, err := db.Exec(builtinStartupQueries); err != nil { - return err - } - return nil -} - -func setupDB(db *sql.DB) error { - // If 'schemaVersion' table does not exist, this must be a new database - rowsSV, err := db.Query(selectSchemaVersionQuery) - if err != nil { - return setupNewDB(db) - } - defer rowsSV.Close() - - // If 'schemaVersion' table exists, read version and potentially upgrade - schemaVersion := 0 - if !rowsSV.Next() { - return errors.New("cannot determine schema version: database file may be corrupt") - } - if err := rowsSV.Scan(&schemaVersion); err != nil { - return err - } - rowsSV.Close() - - // Do migrations - if schemaVersion == currentSchemaVersion { - return nil - } else if schemaVersion > currentSchemaVersion { - return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion) - } - for i := schemaVersion; i < currentSchemaVersion; i++ { - fn, ok := migrations[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 setupNewDB(db *sql.DB) error { - if _, err := db.Exec(createTablesQueries); err != nil { - return err - } - if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { - return err - } - return nil -} diff --git a/user/manager_test.go b/user/manager_test.go index 26a70942..a27f18f1 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -224,7 +224,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { require.Nil(t, err) require.True(t, u.Deleted) - _, err = a.db.Exec("UPDATE user SET deleted = ? WHERE id = ?", time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID) + _, err = testDB(a).Exec("UPDATE user SET deleted = ? WHERE id = ?", time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID) require.Nil(t, err) require.Nil(t, a.RemoveDeletedUsers()) @@ -604,14 +604,14 @@ func TestManager_Token_Expire(t *testing.T) { require.Nil(t, err) // Modify token expiration in database - _, err = a.db.Exec("UPDATE user_token SET expires = 1 WHERE token = ?", token1.Value) + _, err = testDB(a).Exec("UPDATE user_token SET expires = 1 WHERE token = ?", token1.Value) require.Nil(t, err) // Now token1 shouldn't work anymore _, err = a.AuthenticateToken(token1.Value) require.Equal(t, ErrUnauthenticated, err) - result, err := a.db.Query("SELECT * from user_token WHERE token = ?", token1.Value) + result, err := testDB(a).Query("SELECT * from user_token WHERE token = ?", token1.Value) require.Nil(t, err) require.True(t, result.Next()) // Still a matching row require.Nil(t, result.Close()) @@ -619,7 +619,7 @@ func TestManager_Token_Expire(t *testing.T) { // Expire tokens and check database rows require.Nil(t, a.RemoveExpiredTokens()) - result, err = a.db.Query("SELECT * from user_token WHERE token = ?", token1.Value) + result, err = testDB(a).Query("SELECT * from user_token WHERE token = ?", token1.Value) require.Nil(t, err) require.False(t, result.Next()) // No matching row! require.Nil(t, result.Close()) @@ -687,7 +687,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { benTokens = append(benTokens, token.Value) // Manually modify expiry date to avoid sorting issues (this is a hack) - _, err = a.db.Exec(`UPDATE user_token SET expires=? WHERE token=?`, baseTime.Add(time.Duration(i)*time.Minute).Unix(), token.Value) + _, err = testDB(a).Exec(`UPDATE user_token SET expires=? WHERE token=?`, baseTime.Add(time.Duration(i)*time.Minute).Unix(), token.Value) require.Nil(t, err) } @@ -715,14 +715,14 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { } var benCount int - rows, err := a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, ben.ID) + rows, err := testDB(a).Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, ben.ID) require.Nil(t, err) require.True(t, rows.Next()) require.Nil(t, rows.Scan(&benCount)) require.Equal(t, 60, benCount) var philCount int - rows, err = a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, phil.ID) + rows, err = testDB(a).Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, phil.ID) require.Nil(t, err) require.True(t, rows.Next()) require.Nil(t, rows.Scan(&philCount)) @@ -730,15 +730,13 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { } func TestManager_EnqueueStats_ResetStats(t *testing.T) { + filename := filepath.Join(t.TempDir(), "db") conf := &Config{ - Filename: filepath.Join(t.TempDir(), "db"), - StartupQueries: "", DefaultAccess: PermissionReadWrite, BcryptCost: bcrypt.MinCost, QueueWriterInterval: 1500 * time.Millisecond, } - a, err := NewManager(conf) - require.Nil(t, err) + a := newTestManagerFromStoreConfig(t, filename, conf) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) // Baseline: No messages or emails @@ -779,15 +777,13 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) { } func TestManager_EnqueueTokenUpdate(t *testing.T) { + filename := filepath.Join(t.TempDir(), "db") conf := &Config{ - Filename: filepath.Join(t.TempDir(), "db"), - StartupQueries: "", DefaultAccess: PermissionReadWrite, BcryptCost: bcrypt.MinCost, QueueWriterInterval: 500 * time.Millisecond, } - a, err := NewManager(conf) - require.Nil(t, err) + a := newTestManagerFromStoreConfig(t, filename, conf) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) // Create user and token @@ -819,15 +815,13 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) { } func TestManager_ChangeSettings(t *testing.T) { + filename := filepath.Join(t.TempDir(), "db") conf := &Config{ - Filename: filepath.Join(t.TempDir(), "db"), - StartupQueries: "", DefaultAccess: PermissionReadWrite, BcryptCost: bcrypt.MinCost, QueueWriterInterval: 1500 * time.Millisecond, } - a, err := NewManager(conf) - require.Nil(t, err) + a := newTestManagerFromStoreConfig(t, filename, conf) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) // No settings @@ -1053,7 +1047,7 @@ func TestUser_PhoneNumberAddListRemove(t *testing.T) { require.Equal(t, 0, len(phoneNumbers)) // Paranoia check: We do NOT want to keep phone numbers in there - rows, err := a.db.Query(`SELECT * FROM user_phone`) + rows, err := testDB(a).Query(`SELECT * FROM user_phone`) require.Nil(t, err) require.False(t, rows.Next()) require.Nil(t, rows.Close()) @@ -1098,7 +1092,6 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { func TestManager_WithProvisionedUsers(t *testing.T) { f := filepath.Join(t.TempDir(), "user.db") conf := &Config{ - Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, Users: []*User{ @@ -1117,8 +1110,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) { }, }, } - a, err := NewManager(conf) - require.Nil(t, err) + a := newTestManagerFromStoreConfig(t, f, conf) // Manually add user require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false)) @@ -1154,13 +1146,11 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Update the token last access time and origin (so we can check that it is persisted) lastAccessTime := time.Now().Add(time.Hour) lastOrigin := netip.MustParseAddr("1.1.9.9") - err = execTx(a.db, func(tx *sql.Tx) error { - return a.updateTokenLastAccessTx(tx, tokens[0].Value, lastAccessTime.Unix(), lastOrigin.String()) - }) + err = a.store.UpdateTokenLastAccess(tokens[0].Value, lastAccessTime, lastOrigin) require.Nil(t, err) // Re-open the DB (second app start) - require.Nil(t, a.db.Close()) + require.Nil(t, a.Close()) conf.Users = []*User{ {Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, } @@ -1176,8 +1166,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) { {Value: "tk_u48wqendnkx9er21pqqcadlytbutx", Label: "Another token"}, }, } - a, err = NewManager(conf) - require.Nil(t, err) + a = newTestManagerFromStoreConfig(t, f, conf) // Check that the provisioned users are there users, err = a.Users() @@ -1212,12 +1201,11 @@ func TestManager_WithProvisionedUsers(t *testing.T) { require.Error(t, a.ChangePassword("philuser", "new-pass", false)) // Re-open the DB again (third app start) - require.Nil(t, a.db.Close()) + require.Nil(t, a.Close()) conf.Users = []*User{} conf.Access = map[string][]*Grant{} conf.Tokens = map[string][]*Token{} - a, err = NewManager(conf) - require.Nil(t, err) + a = newTestManagerFromStoreConfig(t, f, conf) // Check that the provisioned users are all gone users, err = a.Users() @@ -1237,17 +1225,16 @@ func TestManager_WithProvisionedUsers(t *testing.T) { require.Equal(t, 0, len(tokens)) var count int - a.db.QueryRow("SELECT COUNT(*) FROM user WHERE provisioned = 1").Scan(&count) + testDB(a).QueryRow("SELECT COUNT(*) FROM user WHERE provisioned = 1").Scan(&count) require.Equal(t, 0, count) - a.db.QueryRow("SELECT COUNT(*) FROM user_grant WHERE provisioned = 1").Scan(&count) + testDB(a).QueryRow("SELECT COUNT(*) FROM user_access WHERE provisioned = 1").Scan(&count) require.Equal(t, 0, count) - a.db.QueryRow("SELECT COUNT(*) FROM user_token WHERE provisioned = 1").Scan(&count) + testDB(a).QueryRow("SELECT COUNT(*) FROM user_token WHERE provisioned = 1").Scan(&count) } func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { f := filepath.Join(t.TempDir(), "user.db") conf := &Config{ - Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, Users: []*User{}, @@ -1257,8 +1244,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { }, }, } - a, err := NewManager(conf) - require.Nil(t, err) + a := newTestManagerFromStoreConfig(t, f, conf) // Manually add user require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false)) @@ -1290,7 +1276,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { require.True(t, grants[0].Provisioned) // Provisioned entry // Re-open the DB (second app start) - require.Nil(t, a.db.Close()) + require.Nil(t, a.Close()) conf.Users = []*User{ {Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, } @@ -1299,8 +1285,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { {TopicPattern: "stats", Permission: PermissionReadWrite}, }, } - a, err = NewManager(conf) - require.Nil(t, err) + a = newTestManagerFromStoreConfig(t, f, conf) // Check that the user was "upgraded" to a provisioned user users, err = a.Users() @@ -1383,7 +1368,7 @@ func TestMigrationFrom1(t *testing.T) { // Create manager to trigger migration a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) - checkSchemaVersion(t, a.db) + checkSchemaVersion(t, testDB(a)) users, err := a.Users() require.Nil(t, err) @@ -1526,7 +1511,7 @@ func TestMigrationFrom4(t *testing.T) { // Create manager to trigger migration a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) - checkSchemaVersion(t, a.db) + checkSchemaVersion(t, testDB(a)) // Add another require.Nil(t, a.AllowAccess(Everyone, "left_*", PermissionReadWrite)) @@ -1587,14 +1572,26 @@ func newTestManager(t *testing.T, defaultAccess Permission) *Manager { } func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager { + store, err := NewSQLiteStore(filename, startupQueries) + require.Nil(t, err) conf := &Config{ - Filename: filename, - StartupQueries: startupQueries, DefaultAccess: defaultAccess, BcryptCost: bcryptCost, QueueWriterInterval: statsWriterInterval, } - a, err := NewManager(conf) + a, err := NewManager(store, conf) + require.Nil(t, err) + return a +} + +func testDB(a *Manager) *sql.DB { + return a.store.(*commonStore).db +} + +func newTestManagerFromStoreConfig(t *testing.T, filename string, conf *Config) *Manager { + store, err := NewSQLiteStore(filename, "") + require.Nil(t, err) + a, err := NewManager(store, conf) require.Nil(t, err) return a } diff --git a/user/store.go b/user/store.go new file mode 100644 index 00000000..2a276d19 --- /dev/null +++ b/user/store.go @@ -0,0 +1,992 @@ +package user + +import ( + "database/sql" + "encoding/json" + "errors" + "heckel.io/ntfy/v2/payments" + "heckel.io/ntfy/v2/util" + "net/netip" + "time" +) + +// Store is the interface for a user database store +type Store interface { + // User operations + UserByID(id string) (*User, error) + User(username string) (*User, error) + UserByToken(token string) (*User, error) + UserByStripeCustomer(customerID string) (*User, error) + Users() ([]*User, error) + UsersCount() (int64, error) + AddUser(username, hash string, role Role, provisioned bool) error + RemoveUser(username string) error + MarkUserRemoved(userID string) error + RemoveDeletedUsers() error + ChangePassword(username, hash string) error + ChangeRole(username string, role Role) error + ChangeProvisioned(username string, provisioned bool) error + ChangeSettings(userID string, prefs *Prefs) error + ChangeTier(username, tierCode string) error + ResetTier(username string) error + UpdateStats(userID string, stats *Stats) error + ResetStats() error + // Token operations + CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, provisioned bool) (*Token, error) + Token(userID, token string) (*Token, error) + Tokens(userID string) ([]*Token, error) + AllProvisionedTokens() ([]*Token, error) + ChangeTokenLabel(userID, token, label string) error + ChangeTokenExpiry(userID, token string, expires time.Time) error + UpdateTokenLastAccess(token string, lastAccess time.Time, lastOrigin netip.Addr) error + RemoveToken(userID, token string) error + RemoveExpiredTokens() error + TokenCount(userID string) (int, error) + RemoveExcessTokens(userID string, maxCount int) error + // Access operations + AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) + AllGrants() (map[string][]Grant, error) + Grants(username string) ([]Grant, error) + AllowAccess(username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error + ResetAccess(username, topicPattern string) error + ResetAllProvisionedAccess() error + Reservations(username string) ([]Reservation, error) + HasReservation(username, topic string) (bool, error) + ReservationsCount(username string) (int64, error) + ReservationOwner(topic string) (string, error) + OtherAccessCount(username, topic string) (int, error) + // Tier operations + AddTier(tier *Tier) error + UpdateTier(tier *Tier) error + RemoveTier(code string) error + Tiers() ([]*Tier, error) + Tier(code string) (*Tier, error) + TierByStripePrice(priceID string) (*Tier, error) + // Phone operations + PhoneNumbers(userID string) ([]string, error) + AddPhoneNumber(userID, phoneNumber string) error + RemovePhoneNumber(userID, phoneNumber string) error + // Billing + ChangeBilling(username string, billing *Billing) error + // Internal helpers + UserIDByUsername(username string) (string, error) + // System + Close() error +} + +// storeQueries holds the database-specific SQL queries +type storeQueries struct { + // User queries + selectUserByID string + selectUserByName string + selectUserByToken string + selectUserByStripeID string + selectUsernames string + selectUserCount string + selectUserIDFromUsername string + insertUser string + updateUserPass string + updateUserRole string + updateUserProvisioned string + updateUserPrefs string + updateUserStats string + updateUserStatsResetAll string + updateUserTier string + updateUserDeleted string + deleteUser string + deleteUserTier string + deleteUsersMarked string + // Access queries + selectTopicPerms string + selectUserAllAccess string + selectUserAccess string + selectUserReservations string + selectUserReservationsCount string + selectUserReservationsOwner string + selectUserHasReservation string + selectOtherAccessCount string + upsertUserAccess string + deleteUserAccess string + deleteUserAccessProvisioned string + deleteTopicAccess string + deleteAllAccess string + // Token queries + selectToken string + selectTokens string + selectTokenCount string + selectAllProvisionedTokens string + upsertToken string + updateTokenLabel string + updateTokenExpiry string + updateTokenLastAccess string + deleteToken string + deleteProvisionedToken string + deleteAllToken string + deleteExpiredTokens string + deleteExcessTokens string + // Tier queries + insertTier string + selectTiers string + selectTierByCode string + selectTierByPriceID string + updateTier string + deleteTier string + // Phone queries + selectPhoneNumbers string + insertPhoneNumber string + deletePhoneNumber string + // Billing queries + updateBilling string +} + +// commonStore implements store operations that work across database backends +type commonStore struct { + db *sql.DB + queries storeQueries +} + +// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise +func (s *commonStore) UserByID(id string) (*User, error) { + rows, err := s.db.Query(s.queries.selectUserByID, id) + if err != nil { + return nil, err + } + return s.readUser(rows) +} + +// User returns the user with the given username if it exists, or ErrUserNotFound otherwise +func (s *commonStore) User(username string) (*User, error) { + rows, err := s.db.Query(s.queries.selectUserByName, username) + if err != nil { + return nil, err + } + return s.readUser(rows) +} + +// UserByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise +func (s *commonStore) UserByToken(token string) (*User, error) { + rows, err := s.db.Query(s.queries.selectUserByToken, token, time.Now().Unix()) + if err != nil { + return nil, err + } + return s.readUser(rows) +} + +// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise +func (s *commonStore) UserByStripeCustomer(customerID string) (*User, error) { + rows, err := s.db.Query(s.queries.selectUserByStripeID, customerID) + if err != nil { + return nil, err + } + return s.readUser(rows) +} + +// Users returns a list of users +func (s *commonStore) Users() ([]*User, error) { + rows, err := s.db.Query(s.queries.selectUsernames) + if err != nil { + return nil, err + } + defer rows.Close() + usernames := make([]string, 0) + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + usernames = append(usernames, username) + } + rows.Close() + users := make([]*User, 0) + for _, username := range usernames { + user, err := s.User(username) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, nil +} + +// UsersCount returns the number of users in the database +func (s *commonStore) UsersCount() (int64, error) { + rows, err := s.db.Query(s.queries.selectUserCount) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// AddUser adds a user with the given username, password hash and role +func (s *commonStore) AddUser(username, hash string, role Role, provisioned bool) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + userID := util.RandomStringPrefix(userIDPrefix, userIDLength) + syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) + now := time.Now().Unix() + if _, err := s.db.Exec(s.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil { + if isUniqueConstraintError(err) { + return ErrUserExists + } + return err + } + return nil +} + +// RemoveUser deletes the user with the given username +func (s *commonStore) RemoveUser(username string) error { + if !AllowedUsername(username) { + return ErrInvalidArgument + } + // Rows in user_access, user_token, etc. are deleted via foreign keys + if _, err := s.db.Exec(s.queries.deleteUser, username); err != nil { + return err + } + return nil +} + +// MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens +func (s *commonStore) MarkUserRemoved(userID string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // Get username for deleteUserAccess query + user, err := s.UserByID(userID) + if err != nil { + return err + } + if _, err := tx.Exec(s.queries.deleteUserAccess, user.Name, user.Name); err != nil { + return err + } + if _, err := tx.Exec(s.queries.deleteAllToken, userID); err != nil { + return err + } + deletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix() + if _, err := tx.Exec(s.queries.updateUserDeleted, deletedTime, userID); err != nil { + return err + } + return tx.Commit() +} + +// RemoveDeletedUsers deletes all users that have been marked deleted +func (s *commonStore) RemoveDeletedUsers() error { + if _, err := s.db.Exec(s.queries.deleteUsersMarked, time.Now().Unix()); err != nil { + return err + } + return nil +} + +// ChangePassword changes a user's password +func (s *commonStore) ChangePassword(username, hash string) error { + if _, err := s.db.Exec(s.queries.updateUserPass, hash, username); err != nil { + return err + } + return nil +} + +// ChangeRole changes a user's role +func (s *commonStore) ChangeRole(username string, role Role) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(s.queries.updateUserRole, string(role), username); err != nil { + return err + } + // If changing to admin, remove all access entries + if role == RoleAdmin { + if _, err := tx.Exec(s.queries.deleteUserAccess, username, username); err != nil { + return err + } + } + return tx.Commit() +} + +// ChangeProvisioned changes the provisioned status of a user +func (s *commonStore) ChangeProvisioned(username string, provisioned bool) error { + if _, err := s.db.Exec(s.queries.updateUserProvisioned, provisioned, username); err != nil { + return err + } + return nil +} + +// ChangeSettings persists the user settings +func (s *commonStore) ChangeSettings(userID string, prefs *Prefs) error { + b, err := json.Marshal(prefs) + if err != nil { + return err + } + if _, err := s.db.Exec(s.queries.updateUserPrefs, string(b), userID); err != nil { + return err + } + return nil +} + +// ChangeTier changes a user's tier using the tier code +func (s *commonStore) ChangeTier(username, tierCode string) error { + if _, err := s.db.Exec(s.queries.updateUserTier, tierCode, username); err != nil { + return err + } + return nil +} + +// ResetTier removes the tier from the given user +func (s *commonStore) ResetTier(username string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } + _, err := s.db.Exec(s.queries.deleteUserTier, username) + return err +} + +// UpdateStats updates the user statistics +func (s *commonStore) UpdateStats(userID string, stats *Stats) error { + if _, err := s.db.Exec(s.queries.updateUserStats, stats.Messages, stats.Emails, stats.Calls, userID); err != nil { + return err + } + return nil +} + +// ResetStats resets all user stats in the user database +func (s *commonStore) ResetStats() error { + if _, err := s.db.Exec(s.queries.updateUserStatsResetAll); err != nil { + return err + } + return nil +} +func (s *commonStore) readUser(rows *sql.Rows) (*User, error) { + defer rows.Close() + var id, username, hash, role, prefs, syncTopic string + var provisioned bool + var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString + var messages, emails, calls int64 + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + if !rows.Next() { + return nil, ErrUserNotFound + } + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + user := &User{ + ID: id, + Name: username, + Hash: hash, + Role: Role(role), + Prefs: &Prefs{}, + SyncTopic: syncTopic, + Provisioned: provisioned, + Stats: &Stats{ + Messages: messages, + Emails: emails, + Calls: calls, + }, + Billing: &Billing{ + StripeCustomerID: stripeCustomerID.String, + StripeSubscriptionID: stripeSubscriptionID.String, + StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), + StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), + StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), + StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), + }, + Deleted: deleted.Valid, + } + if err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil { + return nil, err + } + if tierCode.Valid { + user.Tier = &Tier{ + ID: tierID.String, + Code: tierCode.String, + Name: tierName.String, + MessageLimit: messagesLimit.Int64, + MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, + EmailLimit: emailsLimit.Int64, + CallLimit: callsLimit.Int64, + ReservationLimit: reservationsLimit.Int64, + AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, + AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, + AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, + StripeMonthlyPriceID: stripeMonthlyPriceID.String, + StripeYearlyPriceID: stripeYearlyPriceID.String, + } + } + return user, nil +} + +// CreateToken creates a new token +func (s *commonStore) CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, provisioned bool) (*Token, error) { + if _, err := s.db.Exec(s.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil { + return nil, err + } + return &Token{ + Value: token, + Label: label, + LastAccess: lastAccess, + LastOrigin: lastOrigin, + Expires: expires, + Provisioned: provisioned, + }, nil +} + +// Token returns a specific token for a user +func (s *commonStore) Token(userID, token string) (*Token, error) { + rows, err := s.db.Query(s.queries.selectToken, userID, token) + if err != nil { + return nil, err + } + defer rows.Close() + return s.readToken(rows) +} + +// Tokens returns all existing tokens for the user with the given user ID +func (s *commonStore) Tokens(userID string) ([]*Token, error) { + rows, err := s.db.Query(s.queries.selectTokens, userID) + if err != nil { + return nil, err + } + defer rows.Close() + tokens := make([]*Token, 0) + for { + token, err := s.readToken(rows) + if errors.Is(err, ErrTokenNotFound) { + break + } else if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + return tokens, nil +} + +// AllProvisionedTokens returns all provisioned tokens +func (s *commonStore) AllProvisionedTokens() ([]*Token, error) { + rows, err := s.db.Query(s.queries.selectAllProvisionedTokens) + if err != nil { + return nil, err + } + defer rows.Close() + tokens := make([]*Token, 0) + for { + token, err := s.readToken(rows) + if errors.Is(err, ErrTokenNotFound) { + break + } else if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + return tokens, nil +} + +// ChangeTokenLabel updates a token's label +func (s *commonStore) ChangeTokenLabel(userID, token, label string) error { + if _, err := s.db.Exec(s.queries.updateTokenLabel, label, userID, token); err != nil { + return err + } + return nil +} + +// ChangeTokenExpiry updates a token's expiry time +func (s *commonStore) ChangeTokenExpiry(userID, token string, expires time.Time) error { + if _, err := s.db.Exec(s.queries.updateTokenExpiry, expires.Unix(), userID, token); err != nil { + return err + } + return nil +} + +// UpdateTokenLastAccess updates a token's last access time and origin +func (s *commonStore) UpdateTokenLastAccess(token string, lastAccess time.Time, lastOrigin netip.Addr) error { + if _, err := s.db.Exec(s.queries.updateTokenLastAccess, lastAccess.Unix(), lastOrigin.String(), token); err != nil { + return err + } + return nil +} + +// RemoveToken deletes the token +func (s *commonStore) RemoveToken(userID, token string) error { + if token == "" { + return errNoTokenProvided + } + if _, err := s.db.Exec(s.queries.deleteToken, userID, token); err != nil { + return err + } + return nil +} + +// RemoveExpiredTokens deletes all expired tokens from the database +func (s *commonStore) RemoveExpiredTokens() error { + if _, err := s.db.Exec(s.queries.deleteExpiredTokens, time.Now().Unix()); err != nil { + return err + } + return nil +} + +// TokenCount returns the number of tokens for a user +func (s *commonStore) TokenCount(userID string) (int, error) { + rows, err := s.db.Query(s.queries.selectTokenCount, userID) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// RemoveExcessTokens deletes excess tokens beyond the specified maximum +func (s *commonStore) RemoveExcessTokens(userID string, maxCount int) error { + if _, err := s.db.Exec(s.queries.deleteExcessTokens, userID, userID, maxCount); err != nil { + return err + } + return nil +} +func (s *commonStore) readToken(rows *sql.Rows) (*Token, error) { + var token, label, lastOrigin string + var lastAccess, expires int64 + var provisioned bool + if !rows.Next() { + return nil, ErrTokenNotFound + } + if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + lastOriginIP, err := netip.ParseAddr(lastOrigin) + if err != nil { + lastOriginIP = netip.IPv4Unspecified() + } + return &Token{ + Value: token, + Label: label, + LastAccess: time.Unix(lastAccess, 0), + LastOrigin: lastOriginIP, + Expires: time.Unix(expires, 0), + Provisioned: provisioned, + }, nil +} + +// AuthorizeTopicAccess returns the read/write permissions for the given username and topic. +// The found return value indicates whether an ACL entry was found at all. +func (s *commonStore) AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) { + rows, err := s.db.Query(s.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic) + if err != nil { + return false, false, false, err + } + defer rows.Close() + if !rows.Next() { + return false, false, false, nil + } + if err := rows.Scan(&read, &write); err != nil { + return false, false, false, err + } else if err := rows.Err(); err != nil { + return false, false, false, err + } + return read, write, true, nil +} + +// AllGrants returns all user-specific access control entries, mapped to their respective user IDs +func (s *commonStore) AllGrants() (map[string][]Grant, error) { + rows, err := s.db.Query(s.queries.selectUserAllAccess) + if err != nil { + return nil, err + } + defer rows.Close() + grants := make(map[string][]Grant, 0) + for rows.Next() { + var userID, topic string + var read, write, provisioned bool + if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + if _, ok := grants[userID]; !ok { + grants[userID] = make([]Grant, 0) + } + grants[userID] = append(grants[userID], Grant{ + TopicPattern: fromSQLWildcard(topic), + Permission: NewPermission(read, write), + Provisioned: provisioned, + }) + } + return grants, nil +} + +// Grants returns all user-specific access control entries +func (s *commonStore) Grants(username string) ([]Grant, error) { + rows, err := s.db.Query(s.queries.selectUserAccess, username) + if err != nil { + return nil, err + } + defer rows.Close() + grants := make([]Grant, 0) + for rows.Next() { + var topic string + var read, write, provisioned bool + if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + grants = append(grants, Grant{ + TopicPattern: fromSQLWildcard(topic), + Permission: NewPermission(read, write), + Provisioned: provisioned, + }) + } + return grants, nil +} + +// AllowAccess adds or updates an entry in the access control list +func (s *commonStore) AllowAccess(username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) { + return ErrInvalidArgument + } + if _, err := s.db.Exec(s.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned); err != nil { + return err + } + return nil +} + +// ResetAccess removes an access control list entry +func (s *commonStore) ResetAccess(username, topicPattern string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { + return ErrInvalidArgument + } + if username == "" && topicPattern == "" { + _, err := s.db.Exec(s.queries.deleteAllAccess) + return err + } else if topicPattern == "" { + _, err := s.db.Exec(s.queries.deleteUserAccess, username, username) + return err + } + _, err := s.db.Exec(s.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern)) + return err +} + +// ResetAllProvisionedAccess removes all provisioned access control entries +func (s *commonStore) ResetAllProvisionedAccess() error { + if _, err := s.db.Exec(s.queries.deleteUserAccessProvisioned); err != nil { + return err + } + return nil +} + +// Reservations returns all user-owned topics, and the associated everyone-access +func (s *commonStore) Reservations(username string) ([]Reservation, error) { + rows, err := s.db.Query(s.queries.selectUserReservations, Everyone, username) + if err != nil { + return nil, err + } + defer rows.Close() + reservations := make([]Reservation, 0) + for rows.Next() { + var topic string + var ownerRead, ownerWrite bool + var everyoneRead, everyoneWrite sql.NullBool + if err := rows.Scan(&topic, &ownerRead, &ownerWrite, &everyoneRead, &everyoneWrite); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + reservations = append(reservations, Reservation{ + Topic: unescapeUnderscore(topic), + Owner: NewPermission(ownerRead, ownerWrite), + Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool), + }) + } + return reservations, nil +} + +// HasReservation returns true if the given topic access is owned by the user +func (s *commonStore) HasReservation(username, topic string) (bool, error) { + rows, err := s.db.Query(s.queries.selectUserHasReservation, username, escapeUnderscore(topic)) + if err != nil { + return false, err + } + defer rows.Close() + if !rows.Next() { + return false, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +// ReservationsCount returns the number of reservations owned by this user +func (s *commonStore) ReservationsCount(username string) (int64, error) { + rows, err := s.db.Query(s.queries.selectUserReservationsCount, username) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// ReservationOwner returns user ID of the user that owns this topic, or an empty string if it's not owned by anyone +func (s *commonStore) ReservationOwner(topic string) (string, error) { + rows, err := s.db.Query(s.queries.selectUserReservationsOwner, escapeUnderscore(topic)) + if err != nil { + return "", err + } + defer rows.Close() + if !rows.Next() { + return "", nil + } + var ownerUserID string + if err := rows.Scan(&ownerUserID); err != nil { + return "", err + } + return ownerUserID, nil +} + +// OtherAccessCount returns the number of access entries for the given topic that are not owned by the user +func (s *commonStore) OtherAccessCount(username, topic string) (int, error) { + rows, err := s.db.Query(s.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// AddTier creates a new tier in the database +func (s *commonStore) AddTier(tier *Tier) error { + if tier.ID == "" { + tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) + } + if _, err := s.db.Exec(s.queries.insertTier, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { + return err + } + return nil +} + +// UpdateTier updates a tier's properties in the database +func (s *commonStore) UpdateTier(tier *Tier) error { + if _, err := s.db.Exec(s.queries.updateTier, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { + return err + } + return nil +} + +// RemoveTier deletes the tier with the given code +func (s *commonStore) RemoveTier(code string) error { + if !AllowedTier(code) { + return ErrInvalidArgument + } + if _, err := s.db.Exec(s.queries.deleteTier, code); err != nil { + return err + } + return nil +} + +// Tiers returns a list of all Tier structs +func (s *commonStore) Tiers() ([]*Tier, error) { + rows, err := s.db.Query(s.queries.selectTiers) + if err != nil { + return nil, err + } + defer rows.Close() + tiers := make([]*Tier, 0) + for { + tier, err := s.readTier(rows) + if errors.Is(err, ErrTierNotFound) { + break + } else if err != nil { + return nil, err + } + tiers = append(tiers, tier) + } + return tiers, nil +} + +// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist +func (s *commonStore) Tier(code string) (*Tier, error) { + rows, err := s.db.Query(s.queries.selectTierByCode, code) + if err != nil { + return nil, err + } + defer rows.Close() + return s.readTier(rows) +} + +// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist +func (s *commonStore) TierByStripePrice(priceID string) (*Tier, error) { + rows, err := s.db.Query(s.queries.selectTierByPriceID, priceID, priceID) + if err != nil { + return nil, err + } + defer rows.Close() + return s.readTier(rows) +} +func (s *commonStore) readTier(rows *sql.Rows) (*Tier, error) { + var id, code, name string + var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 + if !rows.Next() { + return nil, ErrTierNotFound + } + if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + return &Tier{ + ID: id, + Code: code, + Name: name, + MessageLimit: messagesLimit.Int64, + MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, + EmailLimit: emailsLimit.Int64, + CallLimit: callsLimit.Int64, + ReservationLimit: reservationsLimit.Int64, + AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, + AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, + AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, + StripeMonthlyPriceID: stripeMonthlyPriceID.String, + StripeYearlyPriceID: stripeYearlyPriceID.String, + }, nil +} + +// PhoneNumbers returns all phone numbers for the user with the given user ID +func (s *commonStore) PhoneNumbers(userID string) ([]string, error) { + rows, err := s.db.Query(s.queries.selectPhoneNumbers, userID) + if err != nil { + return nil, err + } + defer rows.Close() + phoneNumbers := make([]string, 0) + for { + phoneNumber, err := s.readPhoneNumber(rows) + if errors.Is(err, ErrPhoneNumberNotFound) { + break + } else if err != nil { + return nil, err + } + phoneNumbers = append(phoneNumbers, phoneNumber) + } + return phoneNumbers, nil +} + +// AddPhoneNumber adds a phone number to the user with the given user ID +func (s *commonStore) AddPhoneNumber(userID, phoneNumber string) error { + if _, err := s.db.Exec(s.queries.insertPhoneNumber, userID, phoneNumber); err != nil { + if isUniqueConstraintError(err) { + return ErrPhoneNumberExists + } + return err + } + return nil +} + +// RemovePhoneNumber deletes a phone number from the user with the given user ID +func (s *commonStore) RemovePhoneNumber(userID, phoneNumber string) error { + _, err := s.db.Exec(s.queries.deletePhoneNumber, userID, phoneNumber) + return err +} +func (s *commonStore) readPhoneNumber(rows *sql.Rows) (string, error) { + var phoneNumber string + if !rows.Next() { + return "", ErrPhoneNumberNotFound + } + if err := rows.Scan(&phoneNumber); err != nil { + return "", err + } else if err := rows.Err(); err != nil { + return "", err + } + return phoneNumber, nil +} + +// ChangeBilling updates a user's billing fields +func (s *commonStore) ChangeBilling(username string, billing *Billing) error { + if _, err := s.db.Exec(s.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { + return err + } + return nil +} + +// UserIDByUsername returns the user ID for the given username +func (s *commonStore) UserIDByUsername(username string) (string, error) { + rows, err := s.db.Query(s.queries.selectUserIDFromUsername, username) + if err != nil { + return "", err + } + defer rows.Close() + if !rows.Next() { + return "", ErrUserNotFound + } + var userID string + if err := rows.Scan(&userID); err != nil { + return "", err + } + return userID, nil +} + +// Close closes the underlying database +func (s *commonStore) Close() error { + return s.db.Close() +} + +// isUniqueConstraintError checks if the error is a unique constraint violation for both SQLite and PostgreSQL +func isUniqueConstraintError(err error) bool { + // Check for SQLite unique constraint error + if sqliteErr, ok := err.(interface{ ExtendedCode() int }); ok { + if sqliteErr.ExtendedCode() == 2067 { // sqlite3.ErrConstraintUnique + return true + } + } + // Check for PostgreSQL unique violation (error code 23505) + if pgErr, ok := err.(interface{ Code() string }); ok { + if pgErr.Code() == "23505" { + return true + } + } + return false +} diff --git a/user/store_postgres.go b/user/store_postgres.go new file mode 100644 index 00000000..bfcf4b54 --- /dev/null +++ b/user/store_postgres.go @@ -0,0 +1,424 @@ +package user + +import ( + "database/sql" + "fmt" + + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver +) + +// PostgreSQL schema and queries +const ( + postgresCreateTablesQueries = ` + 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 ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT) + ON CONFLICT (id) DO NOTHING; + ` + + // User queries + postgresSelectUserByID = ` + SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM "user" u + LEFT JOIN tier t on t.id = u.tier_id + WHERE u.id = $1 + ` + postgresSelectUserByName = ` + SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM "user" u + LEFT JOIN tier t on t.id = u.tier_id + WHERE user_name = $1 + ` + postgresSelectUserByToken = ` + SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM "user" u + JOIN user_token tk on u.id = tk.user_id + LEFT JOIN tier t on t.id = u.tier_id + WHERE tk.token = $1 AND (tk.expires = 0 OR tk.expires >= $2) + ` + postgresSelectUserByStripeID = ` + SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM "user" u + LEFT JOIN tier t on t.id = u.tier_id + WHERE u.stripe_customer_id = $1 + ` + postgresSelectUsernames = ` + SELECT user_name + FROM "user" + ORDER BY + CASE role + WHEN 'admin' THEN 1 + WHEN 'anonymous' THEN 3 + ELSE 2 + END, user_name + ` + postgresSelectUserCount = `SELECT COUNT(*) FROM "user"` + postgresSelectUserIDFromUsername = `SELECT id FROM "user" WHERE user_name = $1` + postgresInsertUser = `INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created) VALUES ($1, $2, $3, $4, $5, $6, $7)` + postgresUpdateUserPass = `UPDATE "user" SET pass = $1 WHERE user_name = $2` + postgresUpdateUserRole = `UPDATE "user" SET role = $1 WHERE user_name = $2` + postgresUpdateUserProvisioned = `UPDATE "user" SET provisioned = $1 WHERE user_name = $2` + postgresUpdateUserPrefs = `UPDATE "user" SET prefs = $1 WHERE id = $2` + postgresUpdateUserStats = `UPDATE "user" SET stats_messages = $1, stats_emails = $2, stats_calls = $3 WHERE id = $4` + postgresUpdateUserStatsResetAll = `UPDATE "user" SET stats_messages = 0, stats_emails = 0, stats_calls = 0` + postgresUpdateUserTier = `UPDATE "user" SET tier_id = (SELECT id FROM tier WHERE code = $1) WHERE user_name = $2` + postgresUpdateUserDeleted = `UPDATE "user" SET deleted = $1 WHERE id = $2` + postgresDeleteUser = `DELETE FROM "user" WHERE user_name = $1` + postgresDeleteUserTier = `UPDATE "user" SET tier_id = null WHERE user_name = $1` + postgresDeleteUsersMarked = `DELETE FROM "user" WHERE deleted < $1` + + // Access queries + postgresSelectTopicPerms = ` + SELECT read, write + FROM user_access a + JOIN "user" u ON u.id = a.user_id + WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\' + ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC + ` + postgresSelectUserAllAccess = ` + SELECT user_id, topic, read, write, provisioned + FROM user_access + ORDER BY LENGTH(topic) DESC, CASE WHEN write THEN 1 ELSE 0 END DESC, CASE WHEN read THEN 1 ELSE 0 END DESC, topic + ` + postgresSelectUserAccess = ` + SELECT topic, read, write, provisioned + FROM user_access + WHERE user_id = (SELECT id FROM "user" WHERE user_name = $1) + ORDER BY LENGTH(topic) DESC, CASE WHEN write THEN 1 ELSE 0 END DESC, CASE WHEN read THEN 1 ELSE 0 END DESC, topic + ` + postgresSelectUserReservations = ` + SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write + FROM user_access a_user + LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM "user" WHERE user_name = $1) + WHERE a_user.user_id = a_user.owner_user_id + AND a_user.owner_user_id = (SELECT id FROM "user" WHERE user_name = $2) + ORDER BY a_user.topic + ` + postgresSelectUserReservationsCount = ` + SELECT COUNT(*) + FROM user_access + WHERE user_id = owner_user_id + AND owner_user_id = (SELECT id FROM "user" WHERE user_name = $1) + ` + postgresSelectUserReservationsOwner = ` + SELECT owner_user_id + FROM user_access + WHERE topic = $1 + AND user_id = owner_user_id + ` + postgresSelectUserHasReservation = ` + SELECT COUNT(*) + FROM user_access + WHERE user_id = owner_user_id + AND owner_user_id = (SELECT id FROM "user" WHERE user_name = $1) + AND topic = $2 + ` + postgresSelectOtherAccessCount = ` + SELECT COUNT(*) + FROM user_access + WHERE (topic = $1 OR $2 LIKE topic ESCAPE '\') + AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM "user" WHERE user_name = $3)) + ` + postgresUpsertUserAccess = ` + INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) + VALUES ( + (SELECT id FROM "user" WHERE user_name = $1), + $2, + $3, + $4, + CASE WHEN $5 = '' THEN NULL ELSE (SELECT id FROM "user" WHERE user_name = $6) END, + $7 + ) + ON CONFLICT (user_id, topic) + DO UPDATE SET read=EXCLUDED.read, write=EXCLUDED.write, owner_user_id=EXCLUDED.owner_user_id, provisioned=EXCLUDED.provisioned + ` + postgresDeleteUserAccess = ` + DELETE FROM user_access + WHERE user_id = (SELECT id FROM "user" WHERE user_name = $1) + OR owner_user_id = (SELECT id FROM "user" WHERE user_name = $2) + ` + postgresDeleteUserAccessProvisioned = `DELETE FROM user_access WHERE provisioned = true` + postgresDeleteTopicAccess = ` + DELETE FROM user_access + WHERE (user_id = (SELECT id FROM "user" WHERE user_name = $1) OR owner_user_id = (SELECT id FROM "user" WHERE user_name = $2)) + AND topic = $3 + ` + postgresDeleteAllAccess = `DELETE FROM user_access` + + // Token queries + postgresSelectToken = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1 AND token = $2` + postgresSelectTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1` + postgresSelectTokenCount = `SELECT COUNT(*) FROM user_token WHERE user_id = $1` + postgresSelectAllProvisionedTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = true` + postgresUpsertToken = ` + INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_id, token) + DO UPDATE SET label = EXCLUDED.label, expires = EXCLUDED.expires, provisioned = EXCLUDED.provisioned + ` + postgresUpdateTokenLabel = `UPDATE user_token SET label = $1 WHERE user_id = $2 AND token = $3` + postgresUpdateTokenExpiry = `UPDATE user_token SET expires = $1 WHERE user_id = $2 AND token = $3` + postgresUpdateTokenLastAccess = `UPDATE user_token SET last_access = $1, last_origin = $2 WHERE token = $3` + postgresDeleteToken = `DELETE FROM user_token WHERE user_id = $1 AND token = $2` + postgresDeleteProvisionedToken = `DELETE FROM user_token WHERE token = $1` + postgresDeleteAllToken = `DELETE FROM user_token WHERE user_id = $1` + postgresDeleteExpiredTokens = `DELETE FROM user_token WHERE expires > 0 AND expires < $1` + postgresDeleteExcessTokens = ` + DELETE FROM user_token + WHERE user_id = $1 + AND (user_id, token) NOT IN ( + SELECT user_id, token + FROM user_token + WHERE user_id = $2 + ORDER BY expires DESC + LIMIT $3 + ) + ` + + // Tier queries + postgresInsertTier = ` + INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ` + postgresUpdateTier = ` + UPDATE tier + SET name = $1, messages_limit = $2, messages_expiry_duration = $3, emails_limit = $4, calls_limit = $5, reservations_limit = $6, attachment_file_size_limit = $7, attachment_total_size_limit = $8, attachment_expiry_duration = $9, attachment_bandwidth_limit = $10, stripe_monthly_price_id = $11, stripe_yearly_price_id = $12 + WHERE code = $13 + ` + postgresSelectTiers = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + ` + postgresSelectTierByCode = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + WHERE code = $1 + ` + postgresSelectTierByPriceID = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + WHERE (stripe_monthly_price_id = $1 OR stripe_yearly_price_id = $2) + ` + postgresDeleteTier = `DELETE FROM tier WHERE code = $1` + + // Phone queries + postgresSelectPhoneNumbers = `SELECT phone_number FROM user_phone WHERE user_id = $1` + postgresInsertPhoneNumber = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)` + postgresDeletePhoneNumber = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` + + // Billing queries + postgresUpdateBilling = ` + UPDATE "user" + SET stripe_customer_id = $1, stripe_subscription_id = $2, stripe_subscription_status = $3, stripe_subscription_interval = $4, stripe_subscription_paid_until = $5, stripe_subscription_cancel_at = $6 + WHERE user_name = $7 + ` + + // Schema version queries + postgresSelectSchemaVersionExists = `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'schema_version' + )` + postgresSelectSchemaVersion = `SELECT version FROM schema_version WHERE store = 'user'` + postgresInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` +) + +// NewPostgresStore creates a new PostgreSQL-backed user store +func NewPostgresStore(dsn string) (Store, error) { + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + return nil, err + } + if err := setupPostgresDB(db); err != nil { + return nil, err + } + return &commonStore{ + db: db, + queries: postgresQueries(), + }, nil +} + +func postgresQueries() storeQueries { + return storeQueries{ + // User queries + selectUserByID: postgresSelectUserByID, + selectUserByName: postgresSelectUserByName, + selectUserByToken: postgresSelectUserByToken, + selectUserByStripeID: postgresSelectUserByStripeID, + selectUsernames: postgresSelectUsernames, + selectUserCount: postgresSelectUserCount, + selectUserIDFromUsername: postgresSelectUserIDFromUsername, + insertUser: postgresInsertUser, + updateUserPass: postgresUpdateUserPass, + updateUserRole: postgresUpdateUserRole, + updateUserProvisioned: postgresUpdateUserProvisioned, + updateUserPrefs: postgresUpdateUserPrefs, + updateUserStats: postgresUpdateUserStats, + updateUserStatsResetAll: postgresUpdateUserStatsResetAll, + updateUserTier: postgresUpdateUserTier, + updateUserDeleted: postgresUpdateUserDeleted, + deleteUser: postgresDeleteUser, + deleteUserTier: postgresDeleteUserTier, + deleteUsersMarked: postgresDeleteUsersMarked, + + // Access queries + selectTopicPerms: postgresSelectTopicPerms, + selectUserAllAccess: postgresSelectUserAllAccess, + selectUserAccess: postgresSelectUserAccess, + selectUserReservations: postgresSelectUserReservations, + selectUserReservationsCount: postgresSelectUserReservationsCount, + selectUserReservationsOwner: postgresSelectUserReservationsOwner, + selectUserHasReservation: postgresSelectUserHasReservation, + selectOtherAccessCount: postgresSelectOtherAccessCount, + upsertUserAccess: postgresUpsertUserAccess, + deleteUserAccess: postgresDeleteUserAccess, + deleteUserAccessProvisioned: postgresDeleteUserAccessProvisioned, + deleteTopicAccess: postgresDeleteTopicAccess, + deleteAllAccess: postgresDeleteAllAccess, + + // Token queries + selectToken: postgresSelectToken, + selectTokens: postgresSelectTokens, + selectTokenCount: postgresSelectTokenCount, + selectAllProvisionedTokens: postgresSelectAllProvisionedTokens, + upsertToken: postgresUpsertToken, + updateTokenLabel: postgresUpdateTokenLabel, + updateTokenExpiry: postgresUpdateTokenExpiry, + updateTokenLastAccess: postgresUpdateTokenLastAccess, + deleteToken: postgresDeleteToken, + deleteProvisionedToken: postgresDeleteProvisionedToken, + deleteAllToken: postgresDeleteAllToken, + deleteExpiredTokens: postgresDeleteExpiredTokens, + deleteExcessTokens: postgresDeleteExcessTokens, + + // Tier queries + insertTier: postgresInsertTier, + selectTiers: postgresSelectTiers, + selectTierByCode: postgresSelectTierByCode, + selectTierByPriceID: postgresSelectTierByPriceID, + updateTier: postgresUpdateTier, + deleteTier: postgresDeleteTier, + + // Phone queries + selectPhoneNumbers: postgresSelectPhoneNumbers, + insertPhoneNumber: postgresInsertPhoneNumber, + deletePhoneNumber: postgresDeletePhoneNumber, + + // Billing queries + updateBilling: postgresUpdateBilling, + } +} + +func setupPostgresDB(db *sql.DB) error { + // Check if schema version table exists + var exists bool + if err := db.QueryRow(postgresSelectSchemaVersionExists).Scan(&exists); err != nil { + return err + } + + if !exists { + // New database, create all tables + if _, err := db.Exec(postgresCreateTablesQueries); err != nil { + return err + } + if _, err := db.Exec(postgresInsertSchemaVersion, currentSchemaVersion); err != nil { + return err + } + return nil + } + + // Table exists, check if user store has a row + var schemaVersion int + err := db.QueryRow(postgresSelectSchemaVersion).Scan(&schemaVersion) + if err == sql.ErrNoRows { + // schema_version table exists (e.g. created by webpush) but no user row yet + if _, err := db.Exec(postgresCreateTablesQueries); err != nil { + return err + } + if _, err := db.Exec(postgresInsertSchemaVersion, currentSchemaVersion); err != nil { + return err + } + return nil + } else if err != nil { + return fmt.Errorf("cannot determine schema version: %v", err) + } + + if schemaVersion > currentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion) + } + + // Note: PostgreSQL migrations will be added when needed + // For now, we only support new installations at the latest schema version + + return nil +} diff --git a/user/store_sqlite.go b/user/store_sqlite.go new file mode 100644 index 00000000..b2c26d37 --- /dev/null +++ b/user/store_sqlite.go @@ -0,0 +1,420 @@ +package user + +import ( + "database/sql" + "fmt" + + _ "github.com/mattn/go-sqlite3" // SQLite driver +) + +// SQLite schema and queries +const ( + sqliteCreateTablesQueries = ` + BEGIN; + CREATE TABLE IF NOT EXISTS tier ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + name TEXT NOT NULL, + messages_limit INT NOT NULL, + messages_expiry_duration INT NOT NULL, + emails_limit INT NOT NULL, + calls_limit INT NOT NULL, + reservations_limit INT NOT NULL, + attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, + attachment_expiry_duration INT NOT NULL, + attachment_bandwidth_limit INT NOT NULL, + stripe_monthly_price_id TEXT, + stripe_yearly_price_id TEXT + ); + CREATE UNIQUE INDEX idx_tier_code ON tier (code); + CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); + CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + tier_id TEXT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', + sync_topic TEXT NOT NULL, + provisioned INT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), + stats_emails INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT, + stripe_subscription_interval TEXT, + stripe_subscription_paid_until INT, + stripe_subscription_cancel_at INT, + created INT NOT NULL, + deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + 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 TABLE IF NOT EXISTS user_access ( + user_id TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + owner_user_id INT, + provisioned INT NOT NULL, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_token ( + user_id TEXT NOT NULL, + token TEXT NOT NULL, + label TEXT NOT NULL, + last_access INT NOT NULL, + last_origin TEXT NOT NULL, + expires INT NOT NULL, + provisioned INT NOT NULL, + PRIMARY KEY (user_id, token), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX idx_user_token ON user_token (token); + CREATE TABLE IF NOT EXISTS 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 + ); + 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; + COMMIT; + ` + + sqliteBuiltinStartupQueries = ` + PRAGMA foreign_keys = ON; + ` + + // User queries + sqliteSelectUserByID = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE u.id = ? + ` + sqliteSelectUserByName = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE user = ? + ` + sqliteSelectUserByToken = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + JOIN user_token tk on u.id = tk.user_id + LEFT JOIN tier t on t.id = u.tier_id + WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) + ` + sqliteSelectUserByStripeID = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE u.stripe_customer_id = ? + ` + sqliteSelectUsernames = ` + SELECT user + FROM user + ORDER BY + CASE role + WHEN 'admin' THEN 1 + WHEN 'anonymous' THEN 3 + ELSE 2 + END, user + ` + sqliteSelectUserCount = `SELECT COUNT(*) FROM user` + sqliteSelectUserIDFromUsername = `SELECT id FROM user WHERE user = ?` + sqliteInsertUser = `INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) VALUES (?, ?, ?, ?, ?, ?, ?)` + sqliteUpdateUserPass = `UPDATE user SET pass = ? WHERE user = ?` + sqliteUpdateUserRole = `UPDATE user SET role = ? WHERE user = ?` + sqliteUpdateUserProvisioned = `UPDATE user SET provisioned = ? WHERE user = ?` + sqliteUpdateUserPrefs = `UPDATE user SET prefs = ? WHERE id = ?` + sqliteUpdateUserStats = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` + sqliteUpdateUserStatsResetAll = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` + sqliteUpdateUserTier = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?` + sqliteUpdateUserDeleted = `UPDATE user SET deleted = ? WHERE id = ?` + sqliteDeleteUser = `DELETE FROM user WHERE user = ?` + sqliteDeleteUserTier = `UPDATE user SET tier_id = null WHERE user = ?` + sqliteDeleteUsersMarked = `DELETE FROM user WHERE deleted < ?` + + // Access queries + sqliteSelectTopicPerms = ` + SELECT read, write + FROM user_access a + JOIN user u ON u.id = a.user_id + WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\' + ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC + ` + sqliteSelectUserAllAccess = ` + SELECT user_id, topic, read, write, provisioned + FROM user_access + ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic + ` + sqliteSelectUserAccess = ` + SELECT topic, read, write, provisioned + FROM user_access + WHERE user_id = (SELECT id FROM user WHERE user = ?) + ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic + ` + sqliteSelectUserReservations = ` + SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write + FROM user_access a_user + LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?) + WHERE a_user.user_id = a_user.owner_user_id + AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?) + ORDER BY a_user.topic + ` + sqliteSelectUserReservationsCount = ` + SELECT COUNT(*) + FROM user_access + WHERE user_id = owner_user_id + AND owner_user_id = (SELECT id FROM user WHERE user = ?) + ` + sqliteSelectUserReservationsOwner = ` + SELECT owner_user_id + FROM user_access + WHERE topic = ? + AND user_id = owner_user_id + ` + sqliteSelectUserHasReservation = ` + SELECT COUNT(*) + FROM user_access + WHERE user_id = owner_user_id + AND owner_user_id = (SELECT id FROM user WHERE user = ?) + AND topic = ? + ` + sqliteSelectOtherAccessCount = ` + SELECT COUNT(*) + FROM user_access + WHERE (topic = ? OR ? LIKE topic ESCAPE '\') + AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) + ` + sqliteUpsertUserAccess = ` + INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) + VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?) + ON CONFLICT (user_id, topic) + DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned + ` + sqliteDeleteUserAccess = ` + DELETE FROM user_access + WHERE user_id = (SELECT id FROM user WHERE user = ?) + OR owner_user_id = (SELECT id FROM user WHERE user = ?) + ` + sqliteDeleteUserAccessProvisioned = `DELETE FROM user_access WHERE provisioned = 1` + sqliteDeleteTopicAccess = ` + DELETE FROM user_access + WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)) + AND topic = ? + ` + sqliteDeleteAllAccess = `DELETE FROM user_access` + + // Token queries + sqliteSelectToken = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?` + sqliteSelectTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ?` + sqliteSelectTokenCount = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` + sqliteSelectAllProvisionedTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1` + sqliteUpsertToken = ` + INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, token) + DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned; + ` + sqliteUpdateTokenLabel = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` + sqliteUpdateTokenExpiry = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` + sqliteUpdateTokenLastAccess = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` + sqliteDeleteToken = `DELETE FROM user_token WHERE user_id = ? AND token = ?` + sqliteDeleteProvisionedToken = `DELETE FROM user_token WHERE token = ?` + sqliteDeleteAllToken = `DELETE FROM user_token WHERE user_id = ?` + sqliteDeleteExpiredTokens = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` + sqliteDeleteExcessTokens = ` + DELETE FROM user_token + WHERE user_id = ? + AND (user_id, token) NOT IN ( + SELECT user_id, token + FROM user_token + WHERE user_id = ? + ORDER BY expires DESC + LIMIT ? + ) + ` + + // Tier queries + sqliteInsertTier = ` + INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + sqliteUpdateTier = ` + UPDATE tier + SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? + WHERE code = ? + ` + sqliteSelectTiers = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + ` + sqliteSelectTierByCode = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + WHERE code = ? + ` + sqliteSelectTierByPriceID = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + FROM tier + WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) + ` + sqliteDeleteTier = `DELETE FROM tier WHERE code = ?` + + // Phone queries + sqliteSelectPhoneNumbers = `SELECT phone_number FROM user_phone WHERE user_id = ?` + sqliteInsertPhoneNumber = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` + sqliteDeletePhoneNumber = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` + + // Billing queries + sqliteUpdateBilling = ` + UPDATE user + SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ? + WHERE user = ? + ` +) + +// NewSQLiteStore creates a new SQLite-backed user store +func NewSQLiteStore(filename, startupQueries string) (Store, error) { + db, err := sql.Open("sqlite3", filename) + if err != nil { + return nil, err + } + if err := setupSQLiteDB(db); err != nil { + return nil, err + } + if err := runSQLiteStartupQueries(db, startupQueries); err != nil { + return nil, err + } + return &commonStore{ + db: db, + queries: sqliteQueries(), + }, nil +} + +func sqliteQueries() storeQueries { + return storeQueries{ + selectUserByID: sqliteSelectUserByID, + selectUserByName: sqliteSelectUserByName, + selectUserByToken: sqliteSelectUserByToken, + selectUserByStripeID: sqliteSelectUserByStripeID, + selectUsernames: sqliteSelectUsernames, + selectUserCount: sqliteSelectUserCount, + selectUserIDFromUsername: sqliteSelectUserIDFromUsername, + insertUser: sqliteInsertUser, + updateUserPass: sqliteUpdateUserPass, + updateUserRole: sqliteUpdateUserRole, + updateUserProvisioned: sqliteUpdateUserProvisioned, + updateUserPrefs: sqliteUpdateUserPrefs, + updateUserStats: sqliteUpdateUserStats, + updateUserStatsResetAll: sqliteUpdateUserStatsResetAll, + updateUserTier: sqliteUpdateUserTier, + updateUserDeleted: sqliteUpdateUserDeleted, + deleteUser: sqliteDeleteUser, + deleteUserTier: sqliteDeleteUserTier, + deleteUsersMarked: sqliteDeleteUsersMarked, + selectTopicPerms: sqliteSelectTopicPerms, + selectUserAllAccess: sqliteSelectUserAllAccess, + selectUserAccess: sqliteSelectUserAccess, + selectUserReservations: sqliteSelectUserReservations, + selectUserReservationsCount: sqliteSelectUserReservationsCount, + selectUserReservationsOwner: sqliteSelectUserReservationsOwner, + selectUserHasReservation: sqliteSelectUserHasReservation, + selectOtherAccessCount: sqliteSelectOtherAccessCount, + upsertUserAccess: sqliteUpsertUserAccess, + deleteUserAccess: sqliteDeleteUserAccess, + deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisioned, + deleteTopicAccess: sqliteDeleteTopicAccess, + deleteAllAccess: sqliteDeleteAllAccess, + selectToken: sqliteSelectToken, + selectTokens: sqliteSelectTokens, + selectTokenCount: sqliteSelectTokenCount, + selectAllProvisionedTokens: sqliteSelectAllProvisionedTokens, + upsertToken: sqliteUpsertToken, + updateTokenLabel: sqliteUpdateTokenLabel, + updateTokenExpiry: sqliteUpdateTokenExpiry, + updateTokenLastAccess: sqliteUpdateTokenLastAccess, + deleteToken: sqliteDeleteToken, + deleteProvisionedToken: sqliteDeleteProvisionedToken, + deleteAllToken: sqliteDeleteAllToken, + deleteExpiredTokens: sqliteDeleteExpiredTokens, + deleteExcessTokens: sqliteDeleteExcessTokens, + insertTier: sqliteInsertTier, + selectTiers: sqliteSelectTiers, + selectTierByCode: sqliteSelectTierByCode, + selectTierByPriceID: sqliteSelectTierByPriceID, + updateTier: sqliteUpdateTier, + deleteTier: sqliteDeleteTier, + selectPhoneNumbers: sqliteSelectPhoneNumbers, + insertPhoneNumber: sqliteInsertPhoneNumber, + deletePhoneNumber: sqliteDeletePhoneNumber, + updateBilling: sqliteUpdateBilling, + } +} + +func setupSQLiteDB(db *sql.DB) error { + rowsSV, err := db.Query(selectSchemaVersionQuery) + if err != nil { + return setupNewSQLiteDB(db) + } + defer rowsSV.Close() + schemaVersion := 0 + if !rowsSV.Next() { + return fmt.Errorf("cannot determine schema version: database file may be corrupt") + } + if err := rowsSV.Scan(&schemaVersion); err != nil { + return err + } + rowsSV.Close() + if schemaVersion == currentSchemaVersion { + return nil + } else if schemaVersion > currentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion) + } + for i := schemaVersion; i < currentSchemaVersion; i++ { + fn, ok := migrations[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 setupNewSQLiteDB(db *sql.DB) error { + if _, err := db.Exec(sqliteCreateTablesQueries); err != nil { + return err + } + if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { + return err + } + return nil +} + +func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { + if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil { + return err + } + if startupQueries != "" { + if _, err := db.Exec(startupQueries); err != nil { + return err + } + } + return nil +} diff --git a/user/migrations.go b/user/store_sqlite_migrations.go similarity index 99% rename from user/migrations.go rename to user/store_sqlite_migrations.go index a7eb317f..442bcc05 100644 --- a/user/migrations.go +++ b/user/store_sqlite_migrations.go @@ -2,11 +2,12 @@ package user import ( "database/sql" + "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" ) -// Schema management queries +// SQLite migrations const ( currentSchemaVersion = 6 insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` diff --git a/user/types.go b/user/types.go index c65cb863..be589643 100644 --- a/user/types.go +++ b/user/types.go @@ -242,6 +242,20 @@ const ( everyoneID = "u_everyone" ) +// Config holds the configuration for the user Manager +type Config struct { + Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite) + DatabaseURL string // Database connection string (PostgreSQL) + StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only) + DefaultAccess Permission // Default permission if no ACL matches + ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands + Users []*User // Predefined users to create on startup + Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant) + Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) + QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database + BcryptCost int // Cost of generated passwords; lowering makes testing faster +} + // Error constants used by the package var ( ErrUnauthenticated = errors.New("unauthenticated") diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index da69c6e1..fdec1f29 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -25,8 +25,8 @@ const ( PRIMARY KEY (subscription_id, topic) ); CREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic); - CREATE TABLE IF NOT EXISTS webpush_schema_version ( - id INT PRIMARY KEY, + CREATE TABLE IF NOT EXISTS schema_version ( + store TEXT PRIMARY KEY, version INT NOT NULL ); ` @@ -65,8 +65,8 @@ const ( // PostgreSQL schema management queries const ( pgCurrentSchemaVersion = 1 - pgInsertSchemaVersion = `INSERT INTO webpush_schema_version VALUES (1, $1)` - pgSelectSchemaVersionQuery = `SELECT version FROM webpush_schema_version WHERE id = 1` + pgInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('webpush', $1)` + pgSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'webpush'` ) // NewPostgresStore creates a new PostgreSQL-backed web push store. @@ -102,12 +102,16 @@ func NewPostgresStore(dsn string) (Store, error) { } func setupPostgresDB(db *sql.DB) error { - // If 'webpush_schema_version' table does not exist, this must be a new database + // If 'schema_version' table does not exist or no webpush row, this must be a new database rows, err := db.Query(pgSelectSchemaVersionQuery) if err != nil { return setupNewPostgresDB(db) } - return rows.Close() + defer rows.Close() + if !rows.Next() { + return setupNewPostgresDB(db) + } + return nil } func setupNewPostgresDB(db *sql.DB) error { From 909c3fe17bbfe0629e78826ad2e2031320dd23b8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Feb 2026 22:56:31 -0500 Subject: [PATCH 191/548] Add Store-level unit tests for SQLite and PostgreSQL backends Add shared test functions in store_test.go covering all Store interface operations (users, tokens, access control, tiers, billing, stats, etc.) with per-backend wrappers in store_sqlite_test.go and store_postgres_test.go following the webpush test pattern. Fix broken isUniqueConstraintError() which used incorrect interface assertions instead of string matching for SQLite/PostgreSQL errors. --- user/store.go | 21 +- user/store_postgres_test.go | 208 ++++++++++++ user/store_sqlite_test.go | 180 +++++++++++ user/store_test.go | 619 ++++++++++++++++++++++++++++++++++++ 4 files changed, 1013 insertions(+), 15 deletions(-) create mode 100644 user/store_postgres_test.go create mode 100644 user/store_sqlite_test.go create mode 100644 user/store_test.go diff --git a/user/store.go b/user/store.go index 2a276d19..bde62658 100644 --- a/user/store.go +++ b/user/store.go @@ -4,10 +4,12 @@ import ( "database/sql" "encoding/json" "errors" + "net/netip" + "strings" + "time" + "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/util" - "net/netip" - "time" ) // Store is the interface for a user database store @@ -976,17 +978,6 @@ func (s *commonStore) Close() error { // isUniqueConstraintError checks if the error is a unique constraint violation for both SQLite and PostgreSQL func isUniqueConstraintError(err error) bool { - // Check for SQLite unique constraint error - if sqliteErr, ok := err.(interface{ ExtendedCode() int }); ok { - if sqliteErr.ExtendedCode() == 2067 { // sqlite3.ErrConstraintUnique - return true - } - } - // Check for PostgreSQL unique violation (error code 23505) - if pgErr, ok := err.(interface{ Code() string }); ok { - if pgErr.Code() == "23505" { - return true - } - } - return false + errStr := err.Error() + return strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "23505") } diff --git a/user/store_postgres_test.go b/user/store_postgres_test.go new file mode 100644 index 00000000..bc3fab68 --- /dev/null +++ b/user/store_postgres_test.go @@ -0,0 +1,208 @@ +package user_test + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +func newTestPostgresStore(t *testing.T) user.Store { + dsn := os.Getenv("NTFY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") + } + // Create a unique schema for this test + schema := fmt.Sprintf("test_%s", util.RandomString(10)) + setupDB, err := sql.Open("pgx", dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + // Open store with search_path set to the new schema + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + store, err := user.NewPostgresStore(u.String()) + require.Nil(t, err) + t.Cleanup(func() { + store.Close() + cleanDB, err := sql.Open("pgx", dsn) + if err == nil { + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + } + }) + return store +} + +func TestPostgresStoreAddUser(t *testing.T) { + testStoreAddUser(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreAddUserAlreadyExists(t *testing.T) { + testStoreAddUserAlreadyExists(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreRemoveUser(t *testing.T) { + testStoreRemoveUser(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUserByID(t *testing.T) { + testStoreUserByID(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUserByToken(t *testing.T) { + testStoreUserByToken(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUserByStripeCustomer(t *testing.T) { + testStoreUserByStripeCustomer(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUsers(t *testing.T) { + testStoreUsers(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUsersCount(t *testing.T) { + testStoreUsersCount(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreChangePassword(t *testing.T) { + testStoreChangePassword(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreChangeRole(t *testing.T) { + testStoreChangeRole(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTokens(t *testing.T) { + testStoreTokens(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTokenChangeLabel(t *testing.T) { + testStoreTokenChangeLabel(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTokenRemove(t *testing.T) { + testStoreTokenRemove(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTokenRemoveExpired(t *testing.T) { + testStoreTokenRemoveExpired(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTokenRemoveExcess(t *testing.T) { + testStoreTokenRemoveExcess(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTokenUpdateLastAccess(t *testing.T) { + testStoreTokenUpdateLastAccess(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreAllowAccess(t *testing.T) { + testStoreAllowAccess(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreAllowAccessReadOnly(t *testing.T) { + testStoreAllowAccessReadOnly(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreResetAccess(t *testing.T) { + testStoreResetAccess(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreResetAccessAll(t *testing.T) { + testStoreResetAccessAll(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreAuthorizeTopicAccess(t *testing.T) { + testStoreAuthorizeTopicAccess(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreAuthorizeTopicAccessNotFound(t *testing.T) { + testStoreAuthorizeTopicAccessNotFound(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreAuthorizeTopicAccessDenyAll(t *testing.T) { + testStoreAuthorizeTopicAccessDenyAll(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreReservations(t *testing.T) { + testStoreReservations(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreReservationsCount(t *testing.T) { + testStoreReservationsCount(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreHasReservation(t *testing.T) { + testStoreHasReservation(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreReservationOwner(t *testing.T) { + testStoreReservationOwner(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTiers(t *testing.T) { + testStoreTiers(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTierUpdate(t *testing.T) { + testStoreTierUpdate(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTierRemove(t *testing.T) { + testStoreTierRemove(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreTierByStripePrice(t *testing.T) { + testStoreTierByStripePrice(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreChangeTier(t *testing.T) { + testStoreChangeTier(t, newTestPostgresStore(t)) +} + +func TestPostgresStorePhoneNumbers(t *testing.T) { + testStorePhoneNumbers(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreChangeSettings(t *testing.T) { + testStoreChangeSettings(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreChangeBilling(t *testing.T) { + testStoreChangeBilling(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreUpdateStats(t *testing.T) { + testStoreUpdateStats(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreResetStats(t *testing.T) { + testStoreResetStats(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreMarkUserRemoved(t *testing.T) { + testStoreMarkUserRemoved(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreRemoveDeletedUsers(t *testing.T) { + testStoreRemoveDeletedUsers(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreAllGrants(t *testing.T) { + testStoreAllGrants(t, newTestPostgresStore(t)) +} + +func TestPostgresStoreOtherAccessCount(t *testing.T) { + testStoreOtherAccessCount(t, newTestPostgresStore(t)) +} diff --git a/user/store_sqlite_test.go b/user/store_sqlite_test.go new file mode 100644 index 00000000..9f9e5a71 --- /dev/null +++ b/user/store_sqlite_test.go @@ -0,0 +1,180 @@ +package user_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" +) + +func newTestSQLiteStore(t *testing.T) user.Store { + store, err := user.NewSQLiteStore(filepath.Join(t.TempDir(), "user.db"), "") + require.Nil(t, err) + t.Cleanup(func() { store.Close() }) + return store +} + +func TestSQLiteStoreAddUser(t *testing.T) { + testStoreAddUser(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreAddUserAlreadyExists(t *testing.T) { + testStoreAddUserAlreadyExists(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreRemoveUser(t *testing.T) { + testStoreRemoveUser(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUserByID(t *testing.T) { + testStoreUserByID(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUserByToken(t *testing.T) { + testStoreUserByToken(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUserByStripeCustomer(t *testing.T) { + testStoreUserByStripeCustomer(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUsers(t *testing.T) { + testStoreUsers(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUsersCount(t *testing.T) { + testStoreUsersCount(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreChangePassword(t *testing.T) { + testStoreChangePassword(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreChangeRole(t *testing.T) { + testStoreChangeRole(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTokens(t *testing.T) { + testStoreTokens(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTokenChangeLabel(t *testing.T) { + testStoreTokenChangeLabel(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTokenRemove(t *testing.T) { + testStoreTokenRemove(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTokenRemoveExpired(t *testing.T) { + testStoreTokenRemoveExpired(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTokenRemoveExcess(t *testing.T) { + testStoreTokenRemoveExcess(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTokenUpdateLastAccess(t *testing.T) { + testStoreTokenUpdateLastAccess(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreAllowAccess(t *testing.T) { + testStoreAllowAccess(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreAllowAccessReadOnly(t *testing.T) { + testStoreAllowAccessReadOnly(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreResetAccess(t *testing.T) { + testStoreResetAccess(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreResetAccessAll(t *testing.T) { + testStoreResetAccessAll(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreAuthorizeTopicAccess(t *testing.T) { + testStoreAuthorizeTopicAccess(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreAuthorizeTopicAccessNotFound(t *testing.T) { + testStoreAuthorizeTopicAccessNotFound(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreAuthorizeTopicAccessDenyAll(t *testing.T) { + testStoreAuthorizeTopicAccessDenyAll(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreReservations(t *testing.T) { + testStoreReservations(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreReservationsCount(t *testing.T) { + testStoreReservationsCount(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreHasReservation(t *testing.T) { + testStoreHasReservation(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreReservationOwner(t *testing.T) { + testStoreReservationOwner(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTiers(t *testing.T) { + testStoreTiers(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTierUpdate(t *testing.T) { + testStoreTierUpdate(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTierRemove(t *testing.T) { + testStoreTierRemove(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreTierByStripePrice(t *testing.T) { + testStoreTierByStripePrice(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreChangeTier(t *testing.T) { + testStoreChangeTier(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStorePhoneNumbers(t *testing.T) { + testStorePhoneNumbers(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreChangeSettings(t *testing.T) { + testStoreChangeSettings(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreChangeBilling(t *testing.T) { + testStoreChangeBilling(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreUpdateStats(t *testing.T) { + testStoreUpdateStats(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreResetStats(t *testing.T) { + testStoreResetStats(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreMarkUserRemoved(t *testing.T) { + testStoreMarkUserRemoved(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreRemoveDeletedUsers(t *testing.T) { + testStoreRemoveDeletedUsers(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreAllGrants(t *testing.T) { + testStoreAllGrants(t, newTestSQLiteStore(t)) +} + +func TestSQLiteStoreOtherAccessCount(t *testing.T) { + testStoreOtherAccessCount(t, newTestSQLiteStore(t)) +} diff --git a/user/store_test.go b/user/store_test.go new file mode 100644 index 00000000..da012bf2 --- /dev/null +++ b/user/store_test.go @@ -0,0 +1,619 @@ +package user_test + +import ( + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" +) + +func testStoreAddUser(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) + require.Equal(t, user.RoleUser, u.Role) + require.False(t, u.Provisioned) + require.NotEmpty(t, u.ID) + require.NotEmpty(t, u.SyncTopic) +} + +func testStoreAddUserAlreadyExists(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Equal(t, user.ErrUserExists, store.AddUser("phil", "philhash", user.RoleUser, false)) +} + +func testStoreRemoveUser(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) + + require.Nil(t, store.RemoveUser("phil")) + _, err = store.User("phil") + require.Equal(t, user.ErrUserNotFound, err) +} + +func testStoreUserByID(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleAdmin, false)) + u, err := store.User("phil") + require.Nil(t, err) + + u2, err := store.UserByID(u.ID) + require.Nil(t, err) + require.Equal(t, u.Name, u2.Name) + require.Equal(t, u.ID, u2.ID) +} + +func testStoreUserByToken(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + tk, err := store.CreateToken(u.ID, "tk_test123", "test token", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(24*time.Hour), false) + require.Nil(t, err) + require.Equal(t, "tk_test123", tk.Value) + + u2, err := store.UserByToken(tk.Value) + require.Nil(t, err) + require.Equal(t, "phil", u2.Name) +} + +func testStoreUserByStripeCustomer(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.ChangeBilling("phil", &user.Billing{ + StripeCustomerID: "cus_test123", + StripeSubscriptionID: "sub_test123", + })) + + u, err := store.UserByStripeCustomer("cus_test123") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) + require.Equal(t, "cus_test123", u.Billing.StripeCustomerID) +} + +func testStoreUsers(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AddUser("ben", "benhash", user.RoleAdmin, false)) + + users, err := store.Users() + require.Nil(t, err) + require.True(t, len(users) >= 3) // phil, ben, and the everyone user +} + +func testStoreUsersCount(t *testing.T, store user.Store) { + count, err := store.UsersCount() + require.Nil(t, err) + require.True(t, count >= 1) // At least the everyone user + + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + count2, err := store.UsersCount() + require.Nil(t, err) + require.Equal(t, count+1, count2) +} + +func testStoreChangePassword(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, "philhash", u.Hash) + + require.Nil(t, store.ChangePassword("phil", "newhash")) + u, err = store.User("phil") + require.Nil(t, err) + require.Equal(t, "newhash", u.Hash) +} + +func testStoreChangeRole(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, user.RoleUser, u.Role) + + require.Nil(t, store.ChangeRole("phil", user.RoleAdmin)) + u, err = store.User("phil") + require.Nil(t, err) + require.Equal(t, user.RoleAdmin, u.Role) +} + +func testStoreTokens(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + now := time.Now() + expires := now.Add(24 * time.Hour) + origin := netip.MustParseAddr("9.9.9.9") + + tk, err := store.CreateToken(u.ID, "tk_abc", "my token", now, origin, expires, false) + require.Nil(t, err) + require.Equal(t, "tk_abc", tk.Value) + require.Equal(t, "my token", tk.Label) + + // Get single token + tk2, err := store.Token(u.ID, "tk_abc") + require.Nil(t, err) + require.Equal(t, "tk_abc", tk2.Value) + require.Equal(t, "my token", tk2.Label) + + // Get all tokens + tokens, err := store.Tokens(u.ID) + require.Nil(t, err) + require.Len(t, tokens, 1) + require.Equal(t, "tk_abc", tokens[0].Value) + + // Token count + count, err := store.TokenCount(u.ID) + require.Nil(t, err) + require.Equal(t, 1, count) +} + +func testStoreTokenChangeLabel(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + _, err = store.CreateToken(u.ID, "tk_abc", "old label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + require.Nil(t, err) + + require.Nil(t, store.ChangeTokenLabel(u.ID, "tk_abc", "new label")) + tk, err := store.Token(u.ID, "tk_abc") + require.Nil(t, err) + require.Equal(t, "new label", tk.Label) +} + +func testStoreTokenRemove(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + require.Nil(t, err) + + require.Nil(t, store.RemoveToken(u.ID, "tk_abc")) + _, err = store.Token(u.ID, "tk_abc") + require.Equal(t, user.ErrTokenNotFound, err) +} + +func testStoreTokenRemoveExpired(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + // Create expired token and active token + _, err = store.CreateToken(u.ID, "tk_expired", "expired", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(-time.Hour), false) + require.Nil(t, err) + _, err = store.CreateToken(u.ID, "tk_active", "active", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + require.Nil(t, err) + + require.Nil(t, store.RemoveExpiredTokens()) + + // Expired token should be gone + _, err = store.Token(u.ID, "tk_expired") + require.Equal(t, user.ErrTokenNotFound, err) + + // Active token should still exist + tk, err := store.Token(u.ID, "tk_active") + require.Nil(t, err) + require.Equal(t, "tk_active", tk.Value) +} + +func testStoreTokenRemoveExcess(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + // Create 3 tokens with increasing expiry + for i, name := range []string{"tk_a", "tk_b", "tk_c"} { + _, err = store.CreateToken(u.ID, name, name, time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Duration(i+1)*time.Hour), false) + require.Nil(t, err) + } + + count, err := store.TokenCount(u.ID) + require.Nil(t, err) + require.Equal(t, 3, count) + + // Remove excess, keep only 2 (the ones with latest expiry: tk_b, tk_c) + require.Nil(t, store.RemoveExcessTokens(u.ID, 2)) + + count, err = store.TokenCount(u.ID) + require.Nil(t, err) + require.Equal(t, 2, count) + + // tk_a should be removed (earliest expiry) + _, err = store.Token(u.ID, "tk_a") + require.Equal(t, user.ErrTokenNotFound, err) + + // tk_b and tk_c should remain + _, err = store.Token(u.ID, "tk_b") + require.Nil(t, err) + _, err = store.Token(u.ID, "tk_c") + require.Nil(t, err) +} + +func testStoreTokenUpdateLastAccess(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + require.Nil(t, err) + + newTime := time.Now().Add(5 * time.Minute) + newOrigin := netip.MustParseAddr("5.5.5.5") + require.Nil(t, store.UpdateTokenLastAccess("tk_abc", newTime, newOrigin)) + + tk, err := store.Token(u.ID, "tk_abc") + require.Nil(t, err) + require.Equal(t, newTime.Unix(), tk.LastAccess.Unix()) + require.Equal(t, newOrigin, tk.LastOrigin) +} + +func testStoreAllowAccess(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false)) + grants, err := store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.Equal(t, "mytopic", grants[0].TopicPattern) + require.True(t, grants[0].Permission.IsReadWrite()) +} + +func testStoreAllowAccessReadOnly(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + + require.Nil(t, store.AllowAccess("phil", "announcements", true, false, "", false)) + grants, err := store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.True(t, grants[0].Permission.IsRead()) + require.False(t, grants[0].Permission.IsWrite()) +} + +func testStoreResetAccess(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) + require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false)) + + grants, err := store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 2) + + require.Nil(t, store.ResetAccess("phil", "topic1")) + grants, err = store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.Equal(t, "topic2", grants[0].TopicPattern) +} + +func testStoreResetAccessAll(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) + require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false)) + + require.Nil(t, store.ResetAccess("phil", "")) + grants, err := store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 0) +} + +func testStoreAuthorizeTopicAccess(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false)) + + read, write, found, err := store.AuthorizeTopicAccess("phil", "mytopic") + require.Nil(t, err) + require.True(t, found) + require.True(t, read) + require.True(t, write) +} + +func testStoreAuthorizeTopicAccessNotFound(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + + _, _, found, err := store.AuthorizeTopicAccess("phil", "other") + require.Nil(t, err) + require.False(t, found) +} + +func testStoreAuthorizeTopicAccessDenyAll(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "secret", false, false, "", false)) + + read, write, found, err := store.AuthorizeTopicAccess("phil", "secret") + require.Nil(t, err) + require.True(t, found) + require.False(t, read) + require.False(t, write) +} + +func testStoreReservations(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) + require.Nil(t, store.AllowAccess(user.Everyone, "mytopic", true, false, "phil", false)) + + reservations, err := store.Reservations("phil") + require.Nil(t, err) + require.Len(t, reservations, 1) + require.Equal(t, "mytopic", reservations[0].Topic) + require.True(t, reservations[0].Owner.IsReadWrite()) + require.True(t, reservations[0].Everyone.IsRead()) + require.False(t, reservations[0].Everyone.IsWrite()) +} + +func testStoreReservationsCount(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "phil", false)) + require.Nil(t, store.AllowAccess("phil", "topic2", true, true, "phil", false)) + + count, err := store.ReservationsCount("phil") + require.Nil(t, err) + require.Equal(t, int64(2), count) +} + +func testStoreHasReservation(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) + + has, err := store.HasReservation("phil", "mytopic") + require.Nil(t, err) + require.True(t, has) + + has, err = store.HasReservation("phil", "other") + require.Nil(t, err) + require.False(t, has) +} + +func testStoreReservationOwner(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) + + owner, err := store.ReservationOwner("mytopic") + require.Nil(t, err) + require.NotEmpty(t, owner) // Returns the user ID + + owner, err = store.ReservationOwner("unowned") + require.Nil(t, err) + require.Empty(t, owner) +} + +func testStoreTiers(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + MessageLimit: 5000, + MessageExpiryDuration: 24 * time.Hour, + EmailLimit: 100, + CallLimit: 10, + ReservationLimit: 20, + AttachmentFileSizeLimit: 10 * 1024 * 1024, + AttachmentTotalSizeLimit: 100 * 1024 * 1024, + AttachmentExpiryDuration: 48 * time.Hour, + AttachmentBandwidthLimit: 500 * 1024 * 1024, + } + require.Nil(t, store.AddTier(tier)) + + // Get by code + t2, err := store.Tier("pro") + require.Nil(t, err) + require.Equal(t, "ti_test", t2.ID) + require.Equal(t, "pro", t2.Code) + require.Equal(t, "Pro", t2.Name) + require.Equal(t, int64(5000), t2.MessageLimit) + require.Equal(t, int64(100), t2.EmailLimit) + require.Equal(t, int64(10), t2.CallLimit) + require.Equal(t, int64(20), t2.ReservationLimit) + + // List all tiers + tiers, err := store.Tiers() + require.Nil(t, err) + require.Len(t, tiers, 1) + require.Equal(t, "pro", tiers[0].Code) +} + +func testStoreTierUpdate(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, store.AddTier(tier)) + + tier.Name = "Professional" + tier.MessageLimit = 9999 + require.Nil(t, store.UpdateTier(tier)) + + t2, err := store.Tier("pro") + require.Nil(t, err) + require.Equal(t, "Professional", t2.Name) + require.Equal(t, int64(9999), t2.MessageLimit) +} + +func testStoreTierRemove(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, store.AddTier(tier)) + + t2, err := store.Tier("pro") + require.Nil(t, err) + require.Equal(t, "pro", t2.Code) + + require.Nil(t, store.RemoveTier("pro")) + _, err = store.Tier("pro") + require.Equal(t, user.ErrTierNotFound, err) +} + +func testStoreTierByStripePrice(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + StripeMonthlyPriceID: "price_monthly", + StripeYearlyPriceID: "price_yearly", + } + require.Nil(t, store.AddTier(tier)) + + t2, err := store.TierByStripePrice("price_monthly") + require.Nil(t, err) + require.Equal(t, "pro", t2.Code) + + t3, err := store.TierByStripePrice("price_yearly") + require.Nil(t, err) + require.Equal(t, "pro", t3.Code) +} + +func testStoreChangeTier(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, store.AddTier(tier)) + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.ChangeTier("phil", "pro")) + + u, err := store.User("phil") + require.Nil(t, err) + require.NotNil(t, u.Tier) + require.Equal(t, "pro", u.Tier.Code) +} + +func testStorePhoneNumbers(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + require.Nil(t, store.AddPhoneNumber(u.ID, "+1234567890")) + require.Nil(t, store.AddPhoneNumber(u.ID, "+0987654321")) + + numbers, err := store.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Len(t, numbers, 2) + + require.Nil(t, store.RemovePhoneNumber(u.ID, "+1234567890")) + numbers, err = store.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Len(t, numbers, 1) + require.Equal(t, "+0987654321", numbers[0]) +} + +func testStoreChangeSettings(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + lang := "de" + prefs := &user.Prefs{Language: &lang} + require.Nil(t, store.ChangeSettings(u.ID, prefs)) + + u2, err := store.User("phil") + require.Nil(t, err) + require.NotNil(t, u2.Prefs) + require.Equal(t, "de", *u2.Prefs.Language) +} + +func testStoreChangeBilling(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + + billing := &user.Billing{ + StripeCustomerID: "cus_123", + StripeSubscriptionID: "sub_456", + } + require.Nil(t, store.ChangeBilling("phil", billing)) + + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, "cus_123", u.Billing.StripeCustomerID) + require.Equal(t, "sub_456", u.Billing.StripeSubscriptionID) +} + +func testStoreUpdateStats(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + stats := &user.Stats{Messages: 42, Emails: 3, Calls: 1} + require.Nil(t, store.UpdateStats(u.ID, stats)) + + u2, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, int64(42), u2.Stats.Messages) + require.Equal(t, int64(3), u2.Stats.Emails) + require.Equal(t, int64(1), u2.Stats.Calls) +} + +func testStoreResetStats(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + require.Nil(t, store.UpdateStats(u.ID, &user.Stats{Messages: 42, Emails: 3, Calls: 1})) + require.Nil(t, store.ResetStats()) + + u2, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, int64(0), u2.Stats.Messages) + require.Equal(t, int64(0), u2.Stats.Emails) + require.Equal(t, int64(0), u2.Stats.Calls) +} + +func testStoreMarkUserRemoved(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + require.Nil(t, store.MarkUserRemoved(u.ID)) + + u2, err := store.User("phil") + require.Nil(t, err) + require.True(t, u2.Deleted) +} + +func testStoreRemoveDeletedUsers(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + require.Nil(t, store.MarkUserRemoved(u.ID)) + + // RemoveDeletedUsers only removes users past the hard-delete duration (7 days). + // Immediately after marking, the user should still exist. + require.Nil(t, store.RemoveDeletedUsers()) + u2, err := store.User("phil") + require.Nil(t, err) + require.True(t, u2.Deleted) +} + +func testStoreAllGrants(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false)) + phil, err := store.User("phil") + require.Nil(t, err) + ben, err := store.User("ben") + require.Nil(t, err) + + require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) + require.Nil(t, store.AllowAccess("ben", "topic2", true, false, "", false)) + + grants, err := store.AllGrants() + require.Nil(t, err) + require.Contains(t, grants, phil.ID) + require.Contains(t, grants, ben.ID) +} + +func testStoreOtherAccessCount(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("ben", "mytopic", true, true, "ben", false)) + + count, err := store.OtherAccessCount("phil", "mytopic") + require.Nil(t, err) + require.Equal(t, 1, count) +} From 1abc1005d01b7660ec58d4c0932d66c84a073982 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 17 Feb 2026 12:04:51 -0500 Subject: [PATCH 192/548] Re-org --- user/manager_test.go | 9 +- user/store_postgres.go | 274 +++++------------- user/store_postgres_schema.go | 113 ++++++++ user/store_sqlite.go | 265 ++++------------- ...e_migrations.go => store_sqlite_schema.go} | 214 +++++++++++--- webpush/store_postgres.go | 10 +- webpush/store_sqlite.go | 22 +- 7 files changed, 443 insertions(+), 464 deletions(-) create mode 100644 user/store_postgres_schema.go rename user/{store_sqlite_migrations.go => store_sqlite_schema.go} (56%) diff --git a/user/manager_test.go b/user/manager_test.go index a27f18f1..e59e3cff 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -3,14 +3,15 @@ package user import ( "database/sql" "fmt" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" - "heckel.io/ntfy/v2/util" "net/netip" "path/filepath" "strings" "testing" "time" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "heckel.io/ntfy/v2/util" ) const minBcryptTimingMillis = int64(40) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources @@ -1563,7 +1564,7 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) { var schemaVersion int require.Nil(t, rows.Scan(&schemaVersion)) - require.Equal(t, currentSchemaVersion, schemaVersion) + require.Equal(t, sqliteCurrentSchemaVersion, schemaVersion) require.Nil(t, rows.Close()) } diff --git a/user/store_postgres.go b/user/store_postgres.go index bfcf4b54..a2a78e3f 100644 --- a/user/store_postgres.go +++ b/user/store_postgres.go @@ -2,87 +2,12 @@ package user import ( "database/sql" - "fmt" _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) -// PostgreSQL schema and queries +// PostgreSQL queries const ( - postgresCreateTablesQueries = ` - 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 ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT) - ON CONFLICT (id) DO NOTHING; - ` - // User queries postgresSelectUserByID = ` SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id @@ -279,15 +204,6 @@ const ( SET stripe_customer_id = $1, stripe_subscription_id = $2, stripe_subscription_status = $3, stripe_subscription_interval = $4, stripe_subscription_paid_until = $5, stripe_subscription_cancel_at = $6 WHERE user_name = $7 ` - - // Schema version queries - postgresSelectSchemaVersionExists = `SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = current_schema() - AND table_name = 'schema_version' - )` - postgresSelectSchemaVersion = `SELECT version FROM schema_version WHERE store = 'user'` - postgresInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` ) // NewPostgresStore creates a new PostgreSQL-backed user store @@ -299,126 +215,78 @@ func NewPostgresStore(dsn string) (Store, error) { if err := db.Ping(); err != nil { return nil, err } - if err := setupPostgresDB(db); err != nil { + if err := setupPostgres(db); err != nil { return nil, err } return &commonStore{ - db: db, - queries: postgresQueries(), + db: db, + queries: storeQueries{ + // User queries + selectUserByID: postgresSelectUserByID, + selectUserByName: postgresSelectUserByName, + selectUserByToken: postgresSelectUserByToken, + selectUserByStripeID: postgresSelectUserByStripeID, + selectUsernames: postgresSelectUsernames, + selectUserCount: postgresSelectUserCount, + selectUserIDFromUsername: postgresSelectUserIDFromUsername, + insertUser: postgresInsertUser, + updateUserPass: postgresUpdateUserPass, + updateUserRole: postgresUpdateUserRole, + updateUserProvisioned: postgresUpdateUserProvisioned, + updateUserPrefs: postgresUpdateUserPrefs, + updateUserStats: postgresUpdateUserStats, + updateUserStatsResetAll: postgresUpdateUserStatsResetAll, + updateUserTier: postgresUpdateUserTier, + updateUserDeleted: postgresUpdateUserDeleted, + deleteUser: postgresDeleteUser, + deleteUserTier: postgresDeleteUserTier, + deleteUsersMarked: postgresDeleteUsersMarked, + + // Access queries + selectTopicPerms: postgresSelectTopicPerms, + selectUserAllAccess: postgresSelectUserAllAccess, + selectUserAccess: postgresSelectUserAccess, + selectUserReservations: postgresSelectUserReservations, + selectUserReservationsCount: postgresSelectUserReservationsCount, + selectUserReservationsOwner: postgresSelectUserReservationsOwner, + selectUserHasReservation: postgresSelectUserHasReservation, + selectOtherAccessCount: postgresSelectOtherAccessCount, + upsertUserAccess: postgresUpsertUserAccess, + deleteUserAccess: postgresDeleteUserAccess, + deleteUserAccessProvisioned: postgresDeleteUserAccessProvisioned, + deleteTopicAccess: postgresDeleteTopicAccess, + deleteAllAccess: postgresDeleteAllAccess, + + // Token queries + selectToken: postgresSelectToken, + selectTokens: postgresSelectTokens, + selectTokenCount: postgresSelectTokenCount, + selectAllProvisionedTokens: postgresSelectAllProvisionedTokens, + upsertToken: postgresUpsertToken, + updateTokenLabel: postgresUpdateTokenLabel, + updateTokenExpiry: postgresUpdateTokenExpiry, + updateTokenLastAccess: postgresUpdateTokenLastAccess, + deleteToken: postgresDeleteToken, + deleteProvisionedToken: postgresDeleteProvisionedToken, + deleteAllToken: postgresDeleteAllToken, + deleteExpiredTokens: postgresDeleteExpiredTokens, + deleteExcessTokens: postgresDeleteExcessTokens, + + // Tier queries + insertTier: postgresInsertTier, + selectTiers: postgresSelectTiers, + selectTierByCode: postgresSelectTierByCode, + selectTierByPriceID: postgresSelectTierByPriceID, + updateTier: postgresUpdateTier, + deleteTier: postgresDeleteTier, + + // Phone queries + selectPhoneNumbers: postgresSelectPhoneNumbers, + insertPhoneNumber: postgresInsertPhoneNumber, + deletePhoneNumber: postgresDeletePhoneNumber, + + // Billing queries + updateBilling: postgresUpdateBilling, + }, }, nil } - -func postgresQueries() storeQueries { - return storeQueries{ - // User queries - selectUserByID: postgresSelectUserByID, - selectUserByName: postgresSelectUserByName, - selectUserByToken: postgresSelectUserByToken, - selectUserByStripeID: postgresSelectUserByStripeID, - selectUsernames: postgresSelectUsernames, - selectUserCount: postgresSelectUserCount, - selectUserIDFromUsername: postgresSelectUserIDFromUsername, - insertUser: postgresInsertUser, - updateUserPass: postgresUpdateUserPass, - updateUserRole: postgresUpdateUserRole, - updateUserProvisioned: postgresUpdateUserProvisioned, - updateUserPrefs: postgresUpdateUserPrefs, - updateUserStats: postgresUpdateUserStats, - updateUserStatsResetAll: postgresUpdateUserStatsResetAll, - updateUserTier: postgresUpdateUserTier, - updateUserDeleted: postgresUpdateUserDeleted, - deleteUser: postgresDeleteUser, - deleteUserTier: postgresDeleteUserTier, - deleteUsersMarked: postgresDeleteUsersMarked, - - // Access queries - selectTopicPerms: postgresSelectTopicPerms, - selectUserAllAccess: postgresSelectUserAllAccess, - selectUserAccess: postgresSelectUserAccess, - selectUserReservations: postgresSelectUserReservations, - selectUserReservationsCount: postgresSelectUserReservationsCount, - selectUserReservationsOwner: postgresSelectUserReservationsOwner, - selectUserHasReservation: postgresSelectUserHasReservation, - selectOtherAccessCount: postgresSelectOtherAccessCount, - upsertUserAccess: postgresUpsertUserAccess, - deleteUserAccess: postgresDeleteUserAccess, - deleteUserAccessProvisioned: postgresDeleteUserAccessProvisioned, - deleteTopicAccess: postgresDeleteTopicAccess, - deleteAllAccess: postgresDeleteAllAccess, - - // Token queries - selectToken: postgresSelectToken, - selectTokens: postgresSelectTokens, - selectTokenCount: postgresSelectTokenCount, - selectAllProvisionedTokens: postgresSelectAllProvisionedTokens, - upsertToken: postgresUpsertToken, - updateTokenLabel: postgresUpdateTokenLabel, - updateTokenExpiry: postgresUpdateTokenExpiry, - updateTokenLastAccess: postgresUpdateTokenLastAccess, - deleteToken: postgresDeleteToken, - deleteProvisionedToken: postgresDeleteProvisionedToken, - deleteAllToken: postgresDeleteAllToken, - deleteExpiredTokens: postgresDeleteExpiredTokens, - deleteExcessTokens: postgresDeleteExcessTokens, - - // Tier queries - insertTier: postgresInsertTier, - selectTiers: postgresSelectTiers, - selectTierByCode: postgresSelectTierByCode, - selectTierByPriceID: postgresSelectTierByPriceID, - updateTier: postgresUpdateTier, - deleteTier: postgresDeleteTier, - - // Phone queries - selectPhoneNumbers: postgresSelectPhoneNumbers, - insertPhoneNumber: postgresInsertPhoneNumber, - deletePhoneNumber: postgresDeletePhoneNumber, - - // Billing queries - updateBilling: postgresUpdateBilling, - } -} - -func setupPostgresDB(db *sql.DB) error { - // Check if schema version table exists - var exists bool - if err := db.QueryRow(postgresSelectSchemaVersionExists).Scan(&exists); err != nil { - return err - } - - if !exists { - // New database, create all tables - if _, err := db.Exec(postgresCreateTablesQueries); err != nil { - return err - } - if _, err := db.Exec(postgresInsertSchemaVersion, currentSchemaVersion); err != nil { - return err - } - return nil - } - - // Table exists, check if user store has a row - var schemaVersion int - err := db.QueryRow(postgresSelectSchemaVersion).Scan(&schemaVersion) - if err == sql.ErrNoRows { - // schema_version table exists (e.g. created by webpush) but no user row yet - if _, err := db.Exec(postgresCreateTablesQueries); err != nil { - return err - } - if _, err := db.Exec(postgresInsertSchemaVersion, currentSchemaVersion); err != nil { - return err - } - return nil - } else if err != nil { - return fmt.Errorf("cannot determine schema version: %v", err) - } - - if schemaVersion > currentSchemaVersion { - return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion) - } - - // Note: PostgreSQL migrations will be added when needed - // For now, we only support new installations at the latest schema version - - return nil -} diff --git a/user/store_postgres_schema.go b/user/store_postgres_schema.go new file mode 100644 index 00000000..c5824970 --- /dev/null +++ b/user/store_postgres_schema.go @@ -0,0 +1,113 @@ +package user + +import ( + "database/sql" + "fmt" +) + +// Initial PostgreSQL schema +const ( + postgresCreateTablesQueries = ` + 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 ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT) + ON CONFLICT (id) DO NOTHING; + ` +) + +// Schema table management queries for Postgres +const ( + postgresCurrentSchemaVersion = 6 + postgresSelectSchemaVersion = `SELECT version FROM schema_version WHERE store = 'user'` + postgresInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` +) + +func setupPostgres(db *sql.DB) error { + var schemaVersion int + err := db.QueryRow(postgresSelectSchemaVersion).Scan(&schemaVersion) + if err != nil { + return setupNewPostgres(db) + } + if schemaVersion > postgresCurrentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion) + } + // Note: PostgreSQL migrations will be added when needed + return nil +} + +func setupNewPostgres(db *sql.DB) error { + if _, err := db.Exec(postgresCreateTablesQueries); err != nil { + return err + } + if _, err := db.Exec(postgresInsertSchemaVersion, postgresCurrentSchemaVersion); err != nil { + return err + } + return nil +} diff --git a/user/store_sqlite.go b/user/store_sqlite.go index b2c26d37..fc5bc7eb 100644 --- a/user/store_sqlite.go +++ b/user/store_sqlite.go @@ -2,102 +2,11 @@ package user import ( "database/sql" - "fmt" _ "github.com/mattn/go-sqlite3" // SQLite driver ) -// SQLite schema and queries const ( - sqliteCreateTablesQueries = ` - BEGIN; - CREATE TABLE IF NOT EXISTS tier ( - id TEXT PRIMARY KEY, - code TEXT NOT NULL, - name TEXT NOT NULL, - messages_limit INT NOT NULL, - messages_expiry_duration INT NOT NULL, - emails_limit INT NOT NULL, - calls_limit INT NOT NULL, - reservations_limit INT NOT NULL, - attachment_file_size_limit INT NOT NULL, - attachment_total_size_limit INT NOT NULL, - attachment_expiry_duration INT NOT NULL, - attachment_bandwidth_limit INT NOT NULL, - stripe_monthly_price_id TEXT, - stripe_yearly_price_id TEXT - ); - CREATE UNIQUE INDEX idx_tier_code ON tier (code); - CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); - CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); - CREATE TABLE IF NOT EXISTS user ( - id TEXT PRIMARY KEY, - tier_id TEXT, - user TEXT NOT NULL, - pass TEXT NOT NULL, - role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, - prefs JSON NOT NULL DEFAULT '{}', - sync_topic TEXT NOT NULL, - provisioned INT NOT NULL, - stats_messages INT NOT NULL DEFAULT (0), - stats_emails INT NOT NULL DEFAULT (0), - stats_calls INT NOT NULL DEFAULT (0), - stripe_customer_id TEXT, - stripe_subscription_id TEXT, - stripe_subscription_status TEXT, - stripe_subscription_interval TEXT, - stripe_subscription_paid_until INT, - stripe_subscription_cancel_at INT, - created INT NOT NULL, - deleted INT, - FOREIGN KEY (tier_id) REFERENCES tier (id) - ); - CREATE UNIQUE INDEX idx_user ON user (user); - 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 TABLE IF NOT EXISTS user_access ( - user_id TEXT NOT NULL, - topic TEXT NOT NULL, - read INT NOT NULL, - write INT NOT NULL, - owner_user_id INT, - provisioned INT NOT NULL, - PRIMARY KEY (user_id, topic), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, - FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS user_token ( - user_id TEXT NOT NULL, - token TEXT NOT NULL, - label TEXT NOT NULL, - last_access INT NOT NULL, - last_origin TEXT NOT NULL, - expires INT NOT NULL, - provisioned INT NOT NULL, - PRIMARY KEY (user_id, token), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX idx_user_token ON user_token (token); - CREATE TABLE IF NOT EXISTS 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 - ); - 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; - COMMIT; - ` - - sqliteBuiltinStartupQueries = ` - PRAGMA foreign_keys = ON; - ` - // User queries sqliteSelectUserByID = ` SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id @@ -295,126 +204,70 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { if err != nil { return nil, err } - if err := setupSQLiteDB(db); err != nil { + if err := setupSQLite(db); err != nil { return nil, err } if err := runSQLiteStartupQueries(db, startupQueries); err != nil { return nil, err } return &commonStore{ - db: db, - queries: sqliteQueries(), + db: db, + queries: storeQueries{ + selectUserByID: sqliteSelectUserByID, + selectUserByName: sqliteSelectUserByName, + selectUserByToken: sqliteSelectUserByToken, + selectUserByStripeID: sqliteSelectUserByStripeID, + selectUsernames: sqliteSelectUsernames, + selectUserCount: sqliteSelectUserCount, + selectUserIDFromUsername: sqliteSelectUserIDFromUsername, + insertUser: sqliteInsertUser, + updateUserPass: sqliteUpdateUserPass, + updateUserRole: sqliteUpdateUserRole, + updateUserProvisioned: sqliteUpdateUserProvisioned, + updateUserPrefs: sqliteUpdateUserPrefs, + updateUserStats: sqliteUpdateUserStats, + updateUserStatsResetAll: sqliteUpdateUserStatsResetAll, + updateUserTier: sqliteUpdateUserTier, + updateUserDeleted: sqliteUpdateUserDeleted, + deleteUser: sqliteDeleteUser, + deleteUserTier: sqliteDeleteUserTier, + deleteUsersMarked: sqliteDeleteUsersMarked, + selectTopicPerms: sqliteSelectTopicPerms, + selectUserAllAccess: sqliteSelectUserAllAccess, + selectUserAccess: sqliteSelectUserAccess, + selectUserReservations: sqliteSelectUserReservations, + selectUserReservationsCount: sqliteSelectUserReservationsCount, + selectUserReservationsOwner: sqliteSelectUserReservationsOwner, + selectUserHasReservation: sqliteSelectUserHasReservation, + selectOtherAccessCount: sqliteSelectOtherAccessCount, + upsertUserAccess: sqliteUpsertUserAccess, + deleteUserAccess: sqliteDeleteUserAccess, + deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisioned, + deleteTopicAccess: sqliteDeleteTopicAccess, + deleteAllAccess: sqliteDeleteAllAccess, + selectToken: sqliteSelectToken, + selectTokens: sqliteSelectTokens, + selectTokenCount: sqliteSelectTokenCount, + selectAllProvisionedTokens: sqliteSelectAllProvisionedTokens, + upsertToken: sqliteUpsertToken, + updateTokenLabel: sqliteUpdateTokenLabel, + updateTokenExpiry: sqliteUpdateTokenExpiry, + updateTokenLastAccess: sqliteUpdateTokenLastAccess, + deleteToken: sqliteDeleteToken, + deleteProvisionedToken: sqliteDeleteProvisionedToken, + deleteAllToken: sqliteDeleteAllToken, + deleteExpiredTokens: sqliteDeleteExpiredTokens, + deleteExcessTokens: sqliteDeleteExcessTokens, + insertTier: sqliteInsertTier, + selectTiers: sqliteSelectTiers, + selectTierByCode: sqliteSelectTierByCode, + selectTierByPriceID: sqliteSelectTierByPriceID, + updateTier: sqliteUpdateTier, + deleteTier: sqliteDeleteTier, + selectPhoneNumbers: sqliteSelectPhoneNumbers, + insertPhoneNumber: sqliteInsertPhoneNumber, + deletePhoneNumber: sqliteDeletePhoneNumber, + updateBilling: sqliteUpdateBilling, + }, }, nil } - -func sqliteQueries() storeQueries { - return storeQueries{ - selectUserByID: sqliteSelectUserByID, - selectUserByName: sqliteSelectUserByName, - selectUserByToken: sqliteSelectUserByToken, - selectUserByStripeID: sqliteSelectUserByStripeID, - selectUsernames: sqliteSelectUsernames, - selectUserCount: sqliteSelectUserCount, - selectUserIDFromUsername: sqliteSelectUserIDFromUsername, - insertUser: sqliteInsertUser, - updateUserPass: sqliteUpdateUserPass, - updateUserRole: sqliteUpdateUserRole, - updateUserProvisioned: sqliteUpdateUserProvisioned, - updateUserPrefs: sqliteUpdateUserPrefs, - updateUserStats: sqliteUpdateUserStats, - updateUserStatsResetAll: sqliteUpdateUserStatsResetAll, - updateUserTier: sqliteUpdateUserTier, - updateUserDeleted: sqliteUpdateUserDeleted, - deleteUser: sqliteDeleteUser, - deleteUserTier: sqliteDeleteUserTier, - deleteUsersMarked: sqliteDeleteUsersMarked, - selectTopicPerms: sqliteSelectTopicPerms, - selectUserAllAccess: sqliteSelectUserAllAccess, - selectUserAccess: sqliteSelectUserAccess, - selectUserReservations: sqliteSelectUserReservations, - selectUserReservationsCount: sqliteSelectUserReservationsCount, - selectUserReservationsOwner: sqliteSelectUserReservationsOwner, - selectUserHasReservation: sqliteSelectUserHasReservation, - selectOtherAccessCount: sqliteSelectOtherAccessCount, - upsertUserAccess: sqliteUpsertUserAccess, - deleteUserAccess: sqliteDeleteUserAccess, - deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisioned, - deleteTopicAccess: sqliteDeleteTopicAccess, - deleteAllAccess: sqliteDeleteAllAccess, - selectToken: sqliteSelectToken, - selectTokens: sqliteSelectTokens, - selectTokenCount: sqliteSelectTokenCount, - selectAllProvisionedTokens: sqliteSelectAllProvisionedTokens, - upsertToken: sqliteUpsertToken, - updateTokenLabel: sqliteUpdateTokenLabel, - updateTokenExpiry: sqliteUpdateTokenExpiry, - updateTokenLastAccess: sqliteUpdateTokenLastAccess, - deleteToken: sqliteDeleteToken, - deleteProvisionedToken: sqliteDeleteProvisionedToken, - deleteAllToken: sqliteDeleteAllToken, - deleteExpiredTokens: sqliteDeleteExpiredTokens, - deleteExcessTokens: sqliteDeleteExcessTokens, - insertTier: sqliteInsertTier, - selectTiers: sqliteSelectTiers, - selectTierByCode: sqliteSelectTierByCode, - selectTierByPriceID: sqliteSelectTierByPriceID, - updateTier: sqliteUpdateTier, - deleteTier: sqliteDeleteTier, - selectPhoneNumbers: sqliteSelectPhoneNumbers, - insertPhoneNumber: sqliteInsertPhoneNumber, - deletePhoneNumber: sqliteDeletePhoneNumber, - updateBilling: sqliteUpdateBilling, - } -} - -func setupSQLiteDB(db *sql.DB) error { - rowsSV, err := db.Query(selectSchemaVersionQuery) - if err != nil { - return setupNewSQLiteDB(db) - } - defer rowsSV.Close() - schemaVersion := 0 - if !rowsSV.Next() { - return fmt.Errorf("cannot determine schema version: database file may be corrupt") - } - if err := rowsSV.Scan(&schemaVersion); err != nil { - return err - } - rowsSV.Close() - if schemaVersion == currentSchemaVersion { - return nil - } else if schemaVersion > currentSchemaVersion { - return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion) - } - for i := schemaVersion; i < currentSchemaVersion; i++ { - fn, ok := migrations[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 setupNewSQLiteDB(db *sql.DB) error { - if _, err := db.Exec(sqliteCreateTablesQueries); err != nil { - return err - } - if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { - return err - } - return nil -} - -func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { - if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil { - return err - } - if startupQueries != "" { - if _, err := db.Exec(startupQueries); err != nil { - return err - } - } - return nil -} diff --git a/user/store_sqlite_migrations.go b/user/store_sqlite_schema.go similarity index 56% rename from user/store_sqlite_migrations.go rename to user/store_sqlite_schema.go index 442bcc05..f8e65cb6 100644 --- a/user/store_sqlite_migrations.go +++ b/user/store_sqlite_schema.go @@ -2,20 +2,116 @@ package user import ( "database/sql" + "fmt" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" ) -// SQLite migrations +// Initial SQLite schema const ( - currentSchemaVersion = 6 - insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` - selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` + sqliteCreateTablesQueries = ` + BEGIN; + CREATE TABLE IF NOT EXISTS tier ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + name TEXT NOT NULL, + messages_limit INT NOT NULL, + messages_expiry_duration INT NOT NULL, + emails_limit INT NOT NULL, + calls_limit INT NOT NULL, + reservations_limit INT NOT NULL, + attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, + attachment_expiry_duration INT NOT NULL, + attachment_bandwidth_limit INT NOT NULL, + stripe_monthly_price_id TEXT, + stripe_yearly_price_id TEXT + ); + CREATE UNIQUE INDEX idx_tier_code ON tier (code); + CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); + CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + tier_id TEXT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', + sync_topic TEXT NOT NULL, + provisioned INT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), + stats_emails INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT, + stripe_subscription_interval TEXT, + stripe_subscription_paid_until INT, + stripe_subscription_cancel_at INT, + created INT NOT NULL, + deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + 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 TABLE IF NOT EXISTS user_access ( + user_id TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + owner_user_id INT, + provisioned INT NOT NULL, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_token ( + user_id TEXT NOT NULL, + token TEXT NOT NULL, + label TEXT NOT NULL, + last_access INT NOT NULL, + last_origin TEXT NOT NULL, + expires INT NOT NULL, + provisioned INT NOT NULL, + PRIMARY KEY (user_id, token), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX idx_user_token ON user_token (token); + CREATE TABLE IF NOT EXISTS 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 + ); + 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; + COMMIT; + ` +) +const ( + sqliteBuiltinStartupQueries = `PRAGMA foreign_keys = ON;` +) + +// Schema version table management for SQLite +const ( + sqliteCurrentSchemaVersion = 6 + sqliteInsertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + sqliteUpdateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` + sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` +) + +// Schema migrations for SQLite +const ( // 1 -> 2 (complex migration!) - migrate1To2CreateTablesQueries = ` + sqliteMigrate1To2CreateTablesQueries = ` ALTER TABLE user RENAME TO user_old; CREATE TABLE IF NOT EXISTS tier ( id TEXT PRIMARY KEY, @@ -83,12 +179,12 @@ const ( VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH()) ON CONFLICT (id) DO NOTHING; ` - migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old` - migrate1To2InsertUserNoTx = ` + sqliteMigrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old` + sqliteMigrate1To2InsertUserNoTx = ` INSERT INTO user (id, user, pass, role, sync_topic, created) SELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ? ` - migrate1To2InsertFromOldTablesAndDropNoTx = ` + sqliteMigrate1To2InsertFromOldTablesAndDropNoTx = ` INSERT INTO user_access (user_id, topic, read, write) SELECT u.id, a.topic, a.read, a.write FROM user u @@ -99,7 +195,7 @@ const ( ` // 2 -> 3 - migrate2To3UpdateQueries = ` + sqliteMigrate2To3UpdateQueries = ` ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT; ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id; ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT; @@ -109,7 +205,7 @@ const ( ` // 3 -> 4 - migrate3To4UpdateQueries = ` + sqliteMigrate3To4UpdateQueries = ` ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0); ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0); CREATE TABLE IF NOT EXISTS user_phone ( @@ -121,12 +217,12 @@ const ( ` // 4 -> 5 - migrate4To5UpdateQueries = ` + sqliteMigrate4To5UpdateQueries = ` UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); ` // 5 -> 6 - migrate5To6UpdateQueries = ` + sqliteMigrate5To6UpdateQueries = ` PRAGMA foreign_keys=off; -- Alter user table: Add provisioned column @@ -221,16 +317,60 @@ const ( ) var ( - migrations = map[int]func(db *sql.DB) error{ - 1: migrateFrom1, - 2: migrateFrom2, - 3: migrateFrom3, - 4: migrateFrom4, - 5: migrateFrom5, + sqliteMigrations = map[int]func(db *sql.DB) error{ + 1: sqliteMigrateFrom1, + 2: sqliteMigrateFrom2, + 3: sqliteMigrateFrom3, + 4: sqliteMigrateFrom4, + 5: sqliteMigrateFrom5, } ) -func migrateFrom1(db *sql.DB) error { +func setupSQLite(db *sql.DB) error { + var schemaVersion int + err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) + if 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(db *sql.DB) error { + if _, err := db.Exec(sqliteCreateTablesQueries); err != nil { + return err + } + if _, err := db.Exec(sqliteInsertSchemaVersion, sqliteCurrentSchemaVersion); err != nil { + return err + } + return nil +} + +func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { + if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil { + return err + } + if startupQueries != "" { + if _, err := db.Exec(startupQueries); err != nil { + return err + } + } + return nil +} + +func sqliteMigrateFrom1(db *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 1 to 2") tx, err := db.Begin() if err != nil { @@ -238,11 +378,11 @@ func migrateFrom1(db *sql.DB) error { } defer tx.Rollback() // Rename user -> user_old, and create new tables - if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil { + 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(migrate1To2SelectAllOldUsernamesNoTx) + rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTx) if err != nil { return err } @@ -261,15 +401,15 @@ func migrateFrom1(db *sql.DB) error { for _, username := range usernames { userID := util.RandomStringPrefix(userIDPrefix, userIDLength) syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) - if _, err := tx.Exec(migrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil { + if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil { return err } } // Migrate old "access" table to "user_access" and drop "access" and "user_old" - if _, err := tx.Exec(migrate1To2InsertFromOldTablesAndDropNoTx); err != nil { + if _, err := tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTx); err != nil { return err } - if _, err := tx.Exec(updateSchemaVersion, 2); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 2); err != nil { return err } if err := tx.Commit(); err != nil { @@ -278,65 +418,65 @@ func migrateFrom1(db *sql.DB) error { return nil } -func migrateFrom2(db *sql.DB) error { +func sqliteMigrateFrom2(db *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 2 to 3") tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() - if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil { + if _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil { return err } - if _, err := tx.Exec(updateSchemaVersion, 3); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 3); err != nil { return err } return tx.Commit() } -func migrateFrom3(db *sql.DB) error { +func sqliteMigrateFrom3(db *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 3 to 4") tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() - if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil { + if _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil { return err } - if _, err := tx.Exec(updateSchemaVersion, 4); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 4); err != nil { return err } return tx.Commit() } -func migrateFrom4(db *sql.DB) error { +func sqliteMigrateFrom4(db *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 4 to 5") tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() - if _, err := tx.Exec(migrate4To5UpdateQueries); err != nil { + if _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil { return err } - if _, err := tx.Exec(updateSchemaVersion, 5); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 5); err != nil { return err } return tx.Commit() } -func migrateFrom5(db *sql.DB) error { +func sqliteMigrateFrom5(db *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 5 to 6") tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() - if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil { + if _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil { return err } - if _, err := tx.Exec(updateSchemaVersion, 6); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 6); err != nil { return err } return tx.Commit() diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index fdec1f29..661e18cd 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -2,6 +2,7 @@ package webpush import ( "database/sql" + "fmt" _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) @@ -102,14 +103,13 @@ func NewPostgresStore(dsn string) (Store, error) { } func setupPostgresDB(db *sql.DB) error { - // If 'schema_version' table does not exist or no webpush row, this must be a new database - rows, err := db.Query(pgSelectSchemaVersionQuery) + var schemaVersion int + err := db.QueryRow(pgSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { return setupNewPostgresDB(db) } - defer rows.Close() - if !rows.Next() { - return setupNewPostgresDB(db) + if schemaVersion > pgCurrentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion) } return nil } diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 97bacf74..07823672 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -2,6 +2,7 @@ package webpush import ( "database/sql" + "fmt" _ "github.com/mattn/go-sqlite3" // SQLite driver ) @@ -82,10 +83,10 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { if err != nil { return nil, err } - if err := setupSQLiteWebPushDB(db); err != nil { + if err := setupSQLite(db); err != nil { return nil, err } - if err := runSQLiteWebPushStartupQueries(db, startupQueries); err != nil { + if err := runSQLiteStartupQueries(db, startupQueries); err != nil { return nil, err } return &commonStore{ @@ -108,16 +109,19 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { }, nil } -func setupSQLiteWebPushDB(db *sql.DB) error { - // If 'schemaVersion' table does not exist, this must be a new database - rows, err := db.Query(sqliteSelectWebPushSchemaVersionQuery) +func setupSQLite(db *sql.DB) error { + var schemaVersion int + err := db.QueryRow(sqliteSelectWebPushSchemaVersionQuery).Scan(&schemaVersion) if err != nil { - return setupNewSQLiteWebPushDB(db) + return setupNewSQLite(db) } - return rows.Close() + if schemaVersion > sqliteCurrentWebPushSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentWebPushSchemaVersion) + } + return nil } -func setupNewSQLiteWebPushDB(db *sql.DB) error { +func setupNewSQLite(db *sql.DB) error { if _, err := db.Exec(sqliteCreateWebPushSubscriptionsTableQuery); err != nil { return err } @@ -127,7 +131,7 @@ func setupNewSQLiteWebPushDB(db *sql.DB) error { return nil } -func runSQLiteWebPushStartupQueries(db *sql.DB, startupQueries string) error { +func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { if _, err := db.Exec(startupQueries); err != nil { return err } From e3a402ed959f55100fd326e11039ca88219d96dd Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 17 Feb 2026 20:14:45 -0500 Subject: [PATCH 193/548] Make user tests work for postgres and sqlite --- user/manager_test.go | 2190 ++++++++++++++++++++++-------------------- user/store.go | 11 +- 2 files changed, 1156 insertions(+), 1045 deletions(-) diff --git a/user/manager_test.go b/user/manager_test.go index e59e3cff..4b929d39 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -4,6 +4,8 @@ import ( "database/sql" "fmt" "net/netip" + "net/url" + "os" "path/filepath" "strings" "testing" @@ -16,139 +18,188 @@ import ( const minBcryptTimingMillis = int64(40) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources +// newStoreFunc creates a Store. Calling it multiple times within the same test +// returns a new Store object pointing at the same underlying data (same SQLite +// file / same PostgreSQL schema), enabling close-and-reopen tests. +type newStoreFunc func() Store + +func forEachBackend(t *testing.T, f func(t *testing.T, newStore newStoreFunc)) { + t.Run("sqlite", func(t *testing.T) { + dir := t.TempDir() + f(t, func() Store { + store, err := NewSQLiteStore(filepath.Join(dir, "user.db"), "") + require.Nil(t, err) + return store + }) + }) + t.Run("postgres", func(t *testing.T) { + dsn := os.Getenv("NTFY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("NTFY_TEST_DATABASE_URL not set") + } + schema := fmt.Sprintf("test_%s", util.RandomString(10)) + setupDB, err := sql.Open("pgx", dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + schemaDSN := u.String() + t.Cleanup(func() { + cleanDB, _ := sql.Open("pgx", dsn) + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + }) + f(t, func() Store { + store, err := NewPostgresStore(schemaDSN) + require.Nil(t, err) + return store + }) + }) +} + func TestManager_FullScenario_Default_DenyAll(t *testing.T) { - a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) - require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - require.Nil(t, a.AddUser("john", "john", RoleUser, false)) - require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) - require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) - require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) - require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair! - require.Nil(t, a.AllowAccess("john", "*", PermissionRead)) - require.Nil(t, a.AllowAccess("john", "mytopic*", PermissionReadWrite)) - require.Nil(t, a.AllowAccess("john", "mytopic_ro*", PermissionRead)) - require.Nil(t, a.AllowAccess("john", "mytopic_deny*", PermissionDenyAll)) - require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead)) - require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite)) - require.Nil(t, a.AllowAccess(Everyone, "up*", PermissionWrite)) // Everyone can write to /up* + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + require.Nil(t, a.AddUser("john", "john", RoleUser, false)) + require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) + require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) + require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair! + require.Nil(t, a.AllowAccess("john", "*", PermissionRead)) + require.Nil(t, a.AllowAccess("john", "mytopic*", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("john", "mytopic_ro*", PermissionRead)) + require.Nil(t, a.AllowAccess("john", "mytopic_deny*", PermissionDenyAll)) + require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead)) + require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite)) + require.Nil(t, a.AllowAccess(Everyone, "up*", PermissionWrite)) // Everyone can write to /up* - phil, err := a.Authenticate("phil", "phil") - require.Nil(t, err) - require.Equal(t, "phil", phil.Name) - require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$")) - require.Equal(t, RoleAdmin, phil.Role) + phil, err := a.Authenticate("phil", "phil") + require.Nil(t, err) + require.Equal(t, "phil", phil.Name) + require.True(t, strings.HasPrefix(phil.Hash, "$2a$04$")) + require.Equal(t, RoleAdmin, phil.Role) - philGrants, err := a.Grants("phil") - require.Nil(t, err) - require.Equal(t, []Grant{}, philGrants) + philGrants, err := a.Grants("phil") + require.Nil(t, err) + require.Equal(t, []Grant{}, philGrants) - ben, err := a.Authenticate("ben", "ben") - require.Nil(t, err) - require.Equal(t, "ben", ben.Name) - require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) - require.Equal(t, RoleUser, ben.Role) + ben, err := a.Authenticate("ben", "ben") + require.Nil(t, err) + require.Equal(t, "ben", ben.Name) + require.True(t, strings.HasPrefix(ben.Hash, "$2a$04$")) + require.Equal(t, RoleUser, ben.Role) - benGrants, err := a.Grants("ben") - require.Nil(t, err) - require.Equal(t, []Grant{ - {"everyonewrite", PermissionDenyAll, false}, - {"mytopic", PermissionReadWrite, false}, - {"writeme", PermissionWrite, false}, - {"readme", PermissionRead, false}, - }, benGrants) + benGrants, err := a.Grants("ben") + require.Nil(t, err) + require.Equal(t, []Grant{ + {"everyonewrite", PermissionDenyAll, false}, + {"mytopic", PermissionReadWrite, false}, + {"writeme", PermissionWrite, false}, + {"readme", PermissionRead, false}, + }, benGrants) - john, err := a.Authenticate("john", "john") - require.Nil(t, err) - require.Equal(t, "john", john.Name) - require.True(t, strings.HasPrefix(john.Hash, "$2a$10$")) - require.Equal(t, RoleUser, john.Role) + john, err := a.Authenticate("john", "john") + require.Nil(t, err) + require.Equal(t, "john", john.Name) + require.True(t, strings.HasPrefix(john.Hash, "$2a$04$")) + require.Equal(t, RoleUser, john.Role) - johnGrants, err := a.Grants("john") - require.Nil(t, err) - require.Equal(t, []Grant{ - {"mytopic_deny*", PermissionDenyAll, false}, - {"mytopic_ro*", PermissionRead, false}, - {"mytopic*", PermissionReadWrite, false}, - {"*", PermissionRead, false}, - }, johnGrants) + johnGrants, err := a.Grants("john") + require.Nil(t, err) + require.Equal(t, []Grant{ + {"mytopic_deny*", PermissionDenyAll, false}, + {"mytopic_ro*", PermissionRead, false}, + {"mytopic*", PermissionReadWrite, false}, + {"*", PermissionRead, false}, + }, johnGrants) - notben, err := a.Authenticate("ben", "this is wrong") - require.Nil(t, notben) - require.Equal(t, ErrUnauthenticated, err) + notben, err := a.Authenticate("ben", "this is wrong") + require.Nil(t, notben) + require.Equal(t, ErrUnauthenticated, err) - // Admin can do everything - require.Nil(t, a.Authorize(phil, "sometopic", PermissionWrite)) - require.Nil(t, a.Authorize(phil, "mytopic", PermissionRead)) - require.Nil(t, a.Authorize(phil, "readme", PermissionWrite)) - require.Nil(t, a.Authorize(phil, "writeme", PermissionWrite)) - require.Nil(t, a.Authorize(phil, "announcements", PermissionWrite)) - require.Nil(t, a.Authorize(phil, "everyonewrite", PermissionWrite)) + // Admin can do everything + require.Nil(t, a.Authorize(phil, "sometopic", PermissionWrite)) + require.Nil(t, a.Authorize(phil, "mytopic", PermissionRead)) + require.Nil(t, a.Authorize(phil, "readme", PermissionWrite)) + require.Nil(t, a.Authorize(phil, "writeme", PermissionWrite)) + require.Nil(t, a.Authorize(phil, "announcements", PermissionWrite)) + require.Nil(t, a.Authorize(phil, "everyonewrite", PermissionWrite)) - // User cannot do everything - require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite)) - require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead)) - require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "readme", PermissionWrite)) - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "writeme", PermissionRead)) - require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) - require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "everyonewrite", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "everyonewrite", PermissionWrite)) - require.Nil(t, a.Authorize(ben, "announcements", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite)) + // User cannot do everything + require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead)) + require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "readme", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "writeme", PermissionRead)) + require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "everyonewrite", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "everyonewrite", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "announcements", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite)) - // User john should have - // "deny" to mytopic_deny*, - // "ro" to mytopic_ro*, - // "rw" to mytopic*, - // "ro" to the rest - require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_deny_case", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_deny_case", PermissionWrite)) - require.Nil(t, a.Authorize(john, "mytopic_ro_test_case", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_ro_test_case", PermissionWrite)) - require.Nil(t, a.Authorize(john, "mytopic_case1", PermissionRead)) - require.Nil(t, a.Authorize(john, "mytopic_case1", PermissionWrite)) - require.Nil(t, a.Authorize(john, "readme", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(john, "writeme", PermissionWrite)) + // User john should have + // "deny" to mytopic_deny*, + // "ro" to mytopic_ro*, + // "rw" to mytopic*, + // "ro" to the rest + require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_deny_case", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_deny_case", PermissionWrite)) + require.Nil(t, a.Authorize(john, "mytopic_ro_test_case", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(john, "mytopic_ro_test_case", PermissionWrite)) + require.Nil(t, a.Authorize(john, "mytopic_case1", PermissionRead)) + require.Nil(t, a.Authorize(john, "mytopic_case1", PermissionWrite)) + require.Nil(t, a.Authorize(john, "readme", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(john, "writeme", PermissionWrite)) - // Everyone else can do barely anything - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionWrite)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopic", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopic", PermissionWrite)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "readme", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "readme", PermissionWrite)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "writeme", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "writeme", PermissionWrite)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "announcements", PermissionWrite)) - require.Nil(t, a.Authorize(nil, "announcements", PermissionRead)) - require.Nil(t, a.Authorize(nil, "everyonewrite", PermissionRead)) - require.Nil(t, a.Authorize(nil, "everyonewrite", PermissionWrite)) - require.Nil(t, a.Authorize(nil, "up1234", PermissionWrite)) // Wildcard permission - require.Nil(t, a.Authorize(nil, "up5678", PermissionWrite)) + // Everyone else can do barely anything + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopic", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopic", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "readme", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "readme", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "writeme", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "writeme", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "announcements", PermissionWrite)) + require.Nil(t, a.Authorize(nil, "announcements", PermissionRead)) + require.Nil(t, a.Authorize(nil, "everyonewrite", PermissionRead)) + require.Nil(t, a.Authorize(nil, "everyonewrite", PermissionWrite)) + require.Nil(t, a.Authorize(nil, "up1234", PermissionWrite)) // Wildcard permission + require.Nil(t, a.Authorize(nil, "up5678", PermissionWrite)) + }) } func TestManager_Access_Order_LengthWriteRead(t *testing.T) { // This test validates issue #914 / #917, i.e. that write permissions are prioritized over read permissions, // and longer ACL rules are prioritized as well. + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + require.Nil(t, a.AllowAccess("ben", "test*", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("ben", "*", PermissionRead)) - a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - require.Nil(t, a.AllowAccess("ben", "test*", PermissionReadWrite)) - require.Nil(t, a.AllowAccess("ben", "*", PermissionRead)) - - ben, err := a.Authenticate("ben", "ben") - require.Nil(t, err) - require.Nil(t, a.Authorize(ben, "any-topic-can-be-read", PermissionRead)) - require.Nil(t, a.Authorize(ben, "this-too", PermissionRead)) - require.Nil(t, a.Authorize(ben, "test123", PermissionWrite)) + ben, err := a.Authenticate("ben", "ben") + require.Nil(t, err) + require.Nil(t, a.Authorize(ben, "any-topic-can-be-read", PermissionRead)) + require.Nil(t, a.Authorize(ben, "this-too", PermissionRead)) + require.Nil(t, a.Authorize(ben, "test123", PermissionWrite)) + }) } func TestManager_AddUser_Invalid(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, false)) - require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", false)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, false)) + require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", false)) + }) } func TestManager_AddUser_Timing(t *testing.T) { @@ -159,1157 +210,1206 @@ func TestManager_AddUser_Timing(t *testing.T) { } func TestManager_AddUser_And_Query(t *testing.T) { - a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) - require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) - require.Nil(t, a.ChangeBilling("user", &Billing{ - StripeCustomerID: "acct_123", - StripeSubscriptionID: "sub_123", - StripeSubscriptionStatus: "active", - StripeSubscriptionInterval: "month", - StripeSubscriptionPaidUntil: time.Now().Add(time.Hour), - StripeSubscriptionCancelAt: time.Unix(0, 0), - })) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) + require.Nil(t, a.ChangeBilling("user", &Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + StripeSubscriptionStatus: "active", + StripeSubscriptionInterval: "month", + StripeSubscriptionPaidUntil: time.Now().Add(time.Hour), + StripeSubscriptionCancelAt: time.Unix(0, 0), + })) - u, err := a.User("user") - require.Nil(t, err) - require.Equal(t, "user", u.Name) + u, err := a.User("user") + require.Nil(t, err) + require.Equal(t, "user", u.Name) - u2, err := a.UserByID(u.ID) - require.Nil(t, err) - require.Equal(t, u.Name, u2.Name) + u2, err := a.UserByID(u.ID) + require.Nil(t, err) + require.Equal(t, u.Name, u2.Name) - u3, err := a.UserByStripeCustomer("acct_123") - require.Nil(t, err) - require.Equal(t, u.ID, u3.ID) + u3, err := a.UserByStripeCustomer("acct_123") + require.Nil(t, err) + require.Equal(t, u.ID, u3.ID) + }) } func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) - // Create user, add reservations and token - require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) - require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead)) + // Create user, add reservations and token + require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) + require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead)) - u, err := a.User("user") - require.Nil(t, err) - require.False(t, u.Deleted) + u, err := a.User("user") + require.Nil(t, err) + require.False(t, u.Deleted) - token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false) - require.Nil(t, err) + token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false) + require.Nil(t, err) - u, err = a.Authenticate("user", "pass") - require.Nil(t, err) + u, err = a.Authenticate("user", "pass") + require.Nil(t, err) - _, err = a.AuthenticateToken(token.Value) - require.Nil(t, err) + _, err = a.AuthenticateToken(token.Value) + require.Nil(t, err) - reservations, err := a.Reservations("user") - require.Nil(t, err) - require.Equal(t, 1, len(reservations)) + reservations, err := a.Reservations("user") + require.Nil(t, err) + require.Equal(t, 1, len(reservations)) - // Mark deleted: cannot auth anymore, and all reservations are gone - require.Nil(t, a.MarkUserRemoved(u)) + // Mark deleted: cannot auth anymore, and all reservations are gone + require.Nil(t, a.MarkUserRemoved(u)) - _, err = a.Authenticate("user", "pass") - require.Equal(t, ErrUnauthenticated, err) + _, err = a.Authenticate("user", "pass") + require.Equal(t, ErrUnauthenticated, err) - _, err = a.AuthenticateToken(token.Value) - require.Equal(t, ErrUnauthenticated, err) + _, err = a.AuthenticateToken(token.Value) + require.Equal(t, ErrUnauthenticated, err) - reservations, err = a.Reservations("user") - require.Nil(t, err) - require.Equal(t, 0, len(reservations)) + reservations, err = a.Reservations("user") + require.Nil(t, err) + require.Equal(t, 0, len(reservations)) - // Make sure user is still there - u, err = a.User("user") - require.Nil(t, err) - require.True(t, u.Deleted) + // Make sure user is still there + u, err = a.User("user") + require.Nil(t, err) + require.True(t, u.Deleted) - _, err = testDB(a).Exec("UPDATE user SET deleted = ? WHERE id = ?", time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID) - require.Nil(t, err) - require.Nil(t, a.RemoveDeletedUsers()) + // Backdate the deleted timestamp so RemoveDeletedUsers will prune the user + q := a.store.(*commonStore).queries.updateUserDeleted + _, err = testDB(a).Exec(q, time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID) + require.Nil(t, err) + require.Nil(t, a.RemoveDeletedUsers()) - _, err = a.User("user") - require.Equal(t, ErrUserNotFound, err) + _, err = a.User("user") + require.Equal(t, ErrUserNotFound, err) + }) } func TestManager_CreateToken_Only_Lower(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) - // Create user, add reservations and token - require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) - u, err := a.User("user") - require.Nil(t, err) + // Create user, add reservations and token + require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) + u, err := a.User("user") + require.Nil(t, err) - token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false) - require.Nil(t, err) - require.Equal(t, token.Value, strings.ToLower(token.Value)) + token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false) + require.Nil(t, err) + require.Equal(t, token.Value, strings.ToLower(token.Value)) + }) } func TestManager_UserManagement(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) - require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) - require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) - require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair! - require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead)) - require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) + require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) + require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair! + require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead)) + require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite)) - // Query user details - phil, err := a.User("phil") - require.Nil(t, err) - require.Equal(t, "phil", phil.Name) - require.True(t, strings.HasPrefix(phil.Hash, "$2a$04$")) // Min cost for testing - require.Equal(t, RoleAdmin, phil.Role) + // Query user details + phil, err := a.User("phil") + require.Nil(t, err) + require.Equal(t, "phil", phil.Name) + require.True(t, strings.HasPrefix(phil.Hash, "$2a$04$")) // Min cost for testing + require.Equal(t, RoleAdmin, phil.Role) - philGrants, err := a.Grants("phil") - require.Nil(t, err) - require.Equal(t, []Grant{}, philGrants) + philGrants, err := a.Grants("phil") + require.Nil(t, err) + require.Equal(t, []Grant{}, philGrants) - ben, err := a.User("ben") - require.Nil(t, err) - require.Equal(t, "ben", ben.Name) - require.True(t, strings.HasPrefix(ben.Hash, "$2a$04$")) // Min cost for testing - require.Equal(t, RoleUser, ben.Role) + ben, err := a.User("ben") + require.Nil(t, err) + require.Equal(t, "ben", ben.Name) + require.True(t, strings.HasPrefix(ben.Hash, "$2a$04$")) // Min cost for testing + require.Equal(t, RoleUser, ben.Role) - benGrants, err := a.Grants("ben") - require.Nil(t, err) - require.Equal(t, []Grant{ - {"everyonewrite", PermissionDenyAll, false}, - {"mytopic", PermissionReadWrite, false}, - {"writeme", PermissionWrite, false}, - {"readme", PermissionRead, false}, - }, benGrants) + benGrants, err := a.Grants("ben") + require.Nil(t, err) + require.Equal(t, []Grant{ + {"everyonewrite", PermissionDenyAll, false}, + {"mytopic", PermissionReadWrite, false}, + {"writeme", PermissionWrite, false}, + {"readme", PermissionRead, false}, + }, benGrants) - everyone, err := a.User(Everyone) - require.Nil(t, err) - require.Equal(t, "*", everyone.Name) - require.Equal(t, "", everyone.Hash) - require.Equal(t, RoleAnonymous, everyone.Role) + everyone, err := a.User(Everyone) + require.Nil(t, err) + require.Equal(t, "*", everyone.Name) + require.Equal(t, "", everyone.Hash) + require.Equal(t, RoleAnonymous, everyone.Role) - everyoneGrants, err := a.Grants(Everyone) - require.Nil(t, err) - require.Equal(t, []Grant{ - {"everyonewrite", PermissionReadWrite, false}, - {"announcements", PermissionRead, false}, - }, everyoneGrants) + everyoneGrants, err := a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, []Grant{ + {"everyonewrite", PermissionReadWrite, false}, + {"announcements", PermissionRead, false}, + }, everyoneGrants) - // Ben: Before revoking - require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) // Overwrite! - require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) - require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) - require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead)) - require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite)) - require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) - require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) + // Ben: Before revoking + require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) // Overwrite! + require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) + require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead)) + require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite)) + require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) + require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) - // Revoke access for "ben" to "mytopic", then check again - require.Nil(t, a.ResetAccess("ben", "mytopic")) - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "mytopic", PermissionWrite)) // Revoked - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "mytopic", PermissionRead)) // Revoked - require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) // Unchanged - require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) // Unchanged + // Revoke access for "ben" to "mytopic", then check again + require.Nil(t, a.ResetAccess("ben", "mytopic")) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "mytopic", PermissionWrite)) // Revoked + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "mytopic", PermissionRead)) // Revoked + require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) // Unchanged + require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) // Unchanged - // Revoke rest of the access - require.Nil(t, a.ResetAccess("ben", "")) - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "readme", PermissionRead)) // Revoked - require.Equal(t, ErrUnauthorized, a.Authorize(ben, "wrtiteme", PermissionWrite)) // Revoked + // Revoke rest of the access + require.Nil(t, a.ResetAccess("ben", "")) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "readme", PermissionRead)) // Revoked + require.Equal(t, ErrUnauthorized, a.Authorize(ben, "wrtiteme", PermissionWrite)) // Revoked - // User list - users, err := a.Users() - require.Nil(t, err) - require.Equal(t, 3, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "ben", users[1].Name) - require.Equal(t, "*", users[2].Name) + // User list + users, err := a.Users() + require.Nil(t, err) + require.Equal(t, 3, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "ben", users[1].Name) + require.Equal(t, "*", users[2].Name) - // Remove user - require.Nil(t, a.RemoveUser("ben")) - _, err = a.User("ben") - require.Equal(t, ErrUserNotFound, err) + // Remove user + require.Nil(t, a.RemoveUser("ben")) + _, err = a.User("ben") + require.Equal(t, ErrUserNotFound, err) - users, err = a.Users() - require.Nil(t, err) - require.Equal(t, 2, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "*", users[1].Name) + users, err = a.Users() + require.Nil(t, err) + require.Equal(t, 2, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "*", users[1].Name) + }) } func TestManager_ChangePassword(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) - require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) + require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true)) - _, err := a.Authenticate("phil", "phil") - require.Nil(t, err) + _, err := a.Authenticate("phil", "phil") + require.Nil(t, err) - _, err = a.Authenticate("jane", "jane") - require.Nil(t, err) + _, err = a.Authenticate("jane", "jane") + require.Nil(t, err) - require.Nil(t, a.ChangePassword("phil", "newpass", false)) - _, err = a.Authenticate("phil", "phil") - require.Equal(t, ErrUnauthenticated, err) - _, err = a.Authenticate("phil", "newpass") - require.Nil(t, err) + require.Nil(t, a.ChangePassword("phil", "newpass", false)) + _, err = a.Authenticate("phil", "phil") + require.Equal(t, ErrUnauthenticated, err) + _, err = a.Authenticate("phil", "newpass") + require.Nil(t, err) - require.Nil(t, a.ChangePassword("jane", "$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true)) - _, err = a.Authenticate("jane", "jane") - require.Equal(t, ErrUnauthenticated, err) - _, err = a.Authenticate("jane", "newpass") - require.Nil(t, err) + require.Nil(t, a.ChangePassword("jane", "$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true)) + _, err = a.Authenticate("jane", "jane") + require.Equal(t, ErrUnauthenticated, err) + _, err = a.Authenticate("jane", "newpass") + require.Nil(t, err) + }) } func TestManager_ChangeRole(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) - require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) - ben, err := a.User("ben") - require.Nil(t, err) - require.Equal(t, RoleUser, ben.Role) + ben, err := a.User("ben") + require.Nil(t, err) + require.Equal(t, RoleUser, ben.Role) - benGrants, err := a.Grants("ben") - require.Nil(t, err) - require.Equal(t, 2, len(benGrants)) + benGrants, err := a.Grants("ben") + require.Nil(t, err) + require.Equal(t, 2, len(benGrants)) - require.Nil(t, a.ChangeRole("ben", RoleAdmin)) + require.Nil(t, a.ChangeRole("ben", RoleAdmin)) - ben, err = a.User("ben") - require.Nil(t, err) - require.Equal(t, RoleAdmin, ben.Role) + ben, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, RoleAdmin, ben.Role) - benGrants, err = a.Grants("ben") - require.Nil(t, err) - require.Equal(t, 0, len(benGrants)) + benGrants, err = a.Grants("ben") + require.Nil(t, err) + require.Equal(t, 0, len(benGrants)) + }) } func TestManager_Reservations(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll)) - require.Nil(t, a.AddReservation("ben", "readme", PermissionRead)) - require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll)) + require.Nil(t, a.AddReservation("ben", "readme", PermissionRead)) + require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead)) - reservations, err := a.Reservations("ben") - require.Nil(t, err) - require.Equal(t, 2, len(reservations)) - require.Equal(t, Reservation{ - Topic: "readme", - Owner: PermissionReadWrite, - Everyone: PermissionRead, - }, reservations[0]) - require.Equal(t, Reservation{ - Topic: "ztopic_", - Owner: PermissionReadWrite, - Everyone: PermissionDenyAll, - }, reservations[1]) + reservations, err := a.Reservations("ben") + require.Nil(t, err) + require.Equal(t, 2, len(reservations)) + require.Equal(t, Reservation{ + Topic: "readme", + Owner: PermissionReadWrite, + Everyone: PermissionRead, + }, reservations[0]) + require.Equal(t, Reservation{ + Topic: "ztopic_", + Owner: PermissionReadWrite, + Everyone: PermissionDenyAll, + }, reservations[1]) - b, err := a.HasReservation("ben", "readme") - require.Nil(t, err) - require.True(t, b) + b, err := a.HasReservation("ben", "readme") + require.Nil(t, err) + require.True(t, b) - b, err = a.HasReservation("ben", "ztopic_") - require.Nil(t, err) - require.True(t, b) + b, err = a.HasReservation("ben", "ztopic_") + require.Nil(t, err) + require.True(t, b) - b, err = a.HasReservation("ben", "ztopicX") // _ != X (used to be a SQL wildcard issue) - require.Nil(t, err) - require.False(t, b) + b, err = a.HasReservation("ben", "ztopicX") // _ != X (used to be a SQL wildcard issue) + require.Nil(t, err) + require.False(t, b) - b, err = a.HasReservation("notben", "readme") - require.Nil(t, err) - require.False(t, b) + b, err = a.HasReservation("notben", "readme") + require.Nil(t, err) + require.False(t, b) - b, err = a.HasReservation("ben", "something-else") - require.Nil(t, err) - require.False(t, b) + b, err = a.HasReservation("ben", "something-else") + require.Nil(t, err) + require.False(t, b) - count, err := a.ReservationsCount("ben") - require.Nil(t, err) - require.Equal(t, int64(2), count) + count, err := a.ReservationsCount("ben") + require.Nil(t, err) + require.Equal(t, int64(2), count) - count, err = a.ReservationsCount("phil") - require.Nil(t, err) - require.Equal(t, int64(0), count) + count, err = a.ReservationsCount("phil") + require.Nil(t, err) + require.Equal(t, int64(0), count) - err = a.AllowReservation("phil", "readme") - require.Equal(t, errTopicOwnedByOthers, err) + err = a.AllowReservation("phil", "readme") + require.Equal(t, errTopicOwnedByOthers, err) - err = a.AllowReservation("phil", "ztopic_") - require.Equal(t, errTopicOwnedByOthers, err) + err = a.AllowReservation("phil", "ztopic_") + require.Equal(t, errTopicOwnedByOthers, err) - err = a.AllowReservation("phil", "ztopicX") - require.Nil(t, err) + err = a.AllowReservation("phil", "ztopicX") + require.Nil(t, err) - err = a.AllowReservation("phil", "not-reserved") - require.Nil(t, err) + err = a.AllowReservation("phil", "not-reserved") + require.Nil(t, err) - // Now remove them again - require.Nil(t, a.RemoveReservations("ben", "ztopic_", "readme")) + // Now remove them again + require.Nil(t, a.RemoveReservations("ben", "ztopic_", "readme")) - count, err = a.ReservationsCount("ben") - require.Nil(t, err) - require.Equal(t, int64(0), count) + count, err = a.ReservationsCount("ben") + require.Nil(t, err) + require.Equal(t, int64(0), count) + }) } func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddTier(&Tier{ - Code: "pro", - Name: "ntfy Pro", - StripeMonthlyPriceID: "price123", - MessageLimit: 5_000, - MessageExpiryDuration: 3 * 24 * time.Hour, - EmailLimit: 50, - ReservationLimit: 5, - AttachmentFileSizeLimit: 52428800, - AttachmentTotalSizeLimit: 524288000, - AttachmentExpiryDuration: 24 * time.Hour, - })) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - require.Nil(t, a.ChangeTier("ben", "pro")) - require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddTier(&Tier{ + Code: "pro", + Name: "ntfy Pro", + StripeMonthlyPriceID: "price123", + MessageLimit: 5_000, + MessageExpiryDuration: 3 * 24 * time.Hour, + EmailLimit: 50, + ReservationLimit: 5, + AttachmentFileSizeLimit: 52428800, + AttachmentTotalSizeLimit: 524288000, + AttachmentExpiryDuration: 24 * time.Hour, + })) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + require.Nil(t, a.ChangeTier("ben", "pro")) + require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll)) - ben, err := a.User("ben") - require.Nil(t, err) - require.Equal(t, RoleUser, ben.Role) - require.Equal(t, "pro", ben.Tier.Code) - require.Equal(t, int64(5000), ben.Tier.MessageLimit) - require.Equal(t, 3*24*time.Hour, ben.Tier.MessageExpiryDuration) - require.Equal(t, int64(50), ben.Tier.EmailLimit) - require.Equal(t, int64(5), ben.Tier.ReservationLimit) - require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit) - require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit) - require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration) + ben, err := a.User("ben") + require.Nil(t, err) + require.Equal(t, RoleUser, ben.Role) + require.Equal(t, "pro", ben.Tier.Code) + require.Equal(t, int64(5000), ben.Tier.MessageLimit) + require.Equal(t, 3*24*time.Hour, ben.Tier.MessageExpiryDuration) + require.Equal(t, int64(50), ben.Tier.EmailLimit) + require.Equal(t, int64(5), ben.Tier.ReservationLimit) + require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit) + require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit) + require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration) - benGrants, err := a.Grants("ben") - require.Nil(t, err) - require.Equal(t, 1, len(benGrants)) - require.Equal(t, PermissionReadWrite, benGrants[0].Permission) + benGrants, err := a.Grants("ben") + require.Nil(t, err) + require.Equal(t, 1, len(benGrants)) + require.Equal(t, PermissionReadWrite, benGrants[0].Permission) - everyoneGrants, err := a.Grants(Everyone) - require.Nil(t, err) - require.Equal(t, 1, len(everyoneGrants)) - require.Equal(t, PermissionDenyAll, everyoneGrants[0].Permission) + everyoneGrants, err := a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, 1, len(everyoneGrants)) + require.Equal(t, PermissionDenyAll, everyoneGrants[0].Permission) - benReservations, err := a.Reservations("ben") - require.Nil(t, err) - require.Equal(t, 1, len(benReservations)) - require.Equal(t, "mytopic", benReservations[0].Topic) - require.Equal(t, PermissionReadWrite, benReservations[0].Owner) - require.Equal(t, PermissionDenyAll, benReservations[0].Everyone) + benReservations, err := a.Reservations("ben") + require.Nil(t, err) + require.Equal(t, 1, len(benReservations)) + require.Equal(t, "mytopic", benReservations[0].Topic) + require.Equal(t, PermissionReadWrite, benReservations[0].Owner) + require.Equal(t, PermissionDenyAll, benReservations[0].Everyone) - // Switch to admin, this should remove all grants and owned ACL entries - require.Nil(t, a.ChangeRole("ben", RoleAdmin)) + // Switch to admin, this should remove all grants and owned ACL entries + require.Nil(t, a.ChangeRole("ben", RoleAdmin)) - benGrants, err = a.Grants("ben") - require.Nil(t, err) - require.Equal(t, 0, len(benGrants)) + benGrants, err = a.Grants("ben") + require.Nil(t, err) + require.Equal(t, 0, len(benGrants)) - everyoneGrants, err = a.Grants(Everyone) - require.Nil(t, err) - require.Equal(t, 0, len(everyoneGrants)) + everyoneGrants, err = a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, 0, len(everyoneGrants)) + }) } func TestManager_Token_Valid(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - u, err := a.User("ben") - require.Nil(t, err) + u, err := a.User("ben") + require.Nil(t, err) - // Create token for user - token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) - require.Nil(t, err) - require.NotEmpty(t, token.Value) - require.Equal(t, "some label", token.Label) - require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix()) + // Create token for user + token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) + require.Nil(t, err) + require.NotEmpty(t, token.Value) + require.Equal(t, "some label", token.Label) + require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix()) - u2, err := a.AuthenticateToken(token.Value) - require.Nil(t, err) - require.Equal(t, u.Name, u2.Name) - require.Equal(t, token.Value, u2.Token) + u2, err := a.AuthenticateToken(token.Value) + require.Nil(t, err) + require.Equal(t, u.Name, u2.Name) + require.Equal(t, token.Value, u2.Token) - token2, err := a.Token(u.ID, token.Value) - require.Nil(t, err) - require.Equal(t, token.Value, token2.Value) - require.Equal(t, "some label", token2.Label) + token2, err := a.Token(u.ID, token.Value) + require.Nil(t, err) + require.Equal(t, token.Value, token2.Value) + require.Equal(t, "some label", token2.Label) - tokens, err := a.Tokens(u.ID) - require.Nil(t, err) - require.Equal(t, 1, len(tokens)) - require.Equal(t, "some label", tokens[0].Label) + tokens, err := a.Tokens(u.ID) + require.Nil(t, err) + require.Equal(t, 1, len(tokens)) + require.Equal(t, "some label", tokens[0].Label) - tokens, err = a.Tokens("u_notauser") - require.Nil(t, err) - require.Equal(t, 0, len(tokens)) + tokens, err = a.Tokens("u_notauser") + require.Nil(t, err) + require.Equal(t, 0, len(tokens)) - // Remove token and auth again - require.Nil(t, a.RemoveToken(u2.ID, u2.Token)) - u3, err := a.AuthenticateToken(token.Value) - require.Equal(t, ErrUnauthenticated, err) - require.Nil(t, u3) + // Remove token and auth again + require.Nil(t, a.RemoveToken(u2.ID, u2.Token)) + u3, err := a.AuthenticateToken(token.Value) + require.Equal(t, ErrUnauthenticated, err) + require.Nil(t, u3) - tokens, err = a.Tokens(u.ID) - require.Nil(t, err) - require.Equal(t, 0, len(tokens)) + tokens, err = a.Tokens(u.ID) + require.Nil(t, err) + require.Equal(t, 0, len(tokens)) + }) } func TestManager_Token_Invalid(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length - require.Nil(t, u) - require.Equal(t, ErrUnauthenticated, err) + u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length + require.Nil(t, u) + require.Equal(t, ErrUnauthenticated, err) - u, err = a.AuthenticateToken("not long enough anyway") - require.Nil(t, u) - require.Equal(t, ErrUnauthenticated, err) + u, err = a.AuthenticateToken("not long enough anyway") + require.Nil(t, u) + require.Equal(t, ErrUnauthenticated, err) + }) } func TestManager_Token_NotFound(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - _, err := a.Token("u_bla", "notfound") - require.Equal(t, ErrTokenNotFound, err) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + _, err := a.Token("u_bla", "notfound") + require.Equal(t, ErrTokenNotFound, err) + }) } func TestManager_Token_Expire(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - u, err := a.User("ben") - require.Nil(t, err) + u, err := a.User("ben") + require.Nil(t, err) - // Create tokens for user - token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) - require.Nil(t, err) - require.NotEmpty(t, token1.Value) - require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix()) + // Create tokens for user + token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) + require.Nil(t, err) + require.NotEmpty(t, token1.Value) + require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix()) - token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) - require.Nil(t, err) - require.NotEmpty(t, token2.Value) - require.NotEqual(t, token1.Value, token2.Value) - require.True(t, time.Now().Add(71*time.Hour).Unix() < token2.Expires.Unix()) + token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) + require.Nil(t, err) + require.NotEmpty(t, token2.Value) + require.NotEqual(t, token1.Value, token2.Value) + require.True(t, time.Now().Add(71*time.Hour).Unix() < token2.Expires.Unix()) - // See that tokens work - _, err = a.AuthenticateToken(token1.Value) - require.Nil(t, err) + // See that tokens work + _, err = a.AuthenticateToken(token1.Value) + require.Nil(t, err) - _, err = a.AuthenticateToken(token2.Value) - require.Nil(t, err) + _, err = a.AuthenticateToken(token2.Value) + require.Nil(t, err) - // Modify token expiration in database - _, err = testDB(a).Exec("UPDATE user_token SET expires = 1 WHERE token = ?", token1.Value) - require.Nil(t, err) + // Expire token1 via the API + _, err = a.ChangeToken(u.ID, token1.Value, nil, util.Time(time.Unix(1, 0))) + require.Nil(t, err) - // Now token1 shouldn't work anymore - _, err = a.AuthenticateToken(token1.Value) - require.Equal(t, ErrUnauthenticated, err) + // Now token1 shouldn't work anymore + _, err = a.AuthenticateToken(token1.Value) + require.Equal(t, ErrUnauthenticated, err) - result, err := testDB(a).Query("SELECT * from user_token WHERE token = ?", token1.Value) - require.Nil(t, err) - require.True(t, result.Next()) // Still a matching row - require.Nil(t, result.Close()) + // But the token row should still exist + tokens, err := a.Tokens(u.ID) + require.Nil(t, err) + require.Equal(t, token1.Value, tokens[0].Value) + require.Equal(t, 2, len(tokens)) - // Expire tokens and check database rows - require.Nil(t, a.RemoveExpiredTokens()) + // Expire tokens and check that token1 is gone + require.Nil(t, a.RemoveExpiredTokens()) - result, err = testDB(a).Query("SELECT * from user_token WHERE token = ?", token1.Value) - require.Nil(t, err) - require.False(t, result.Next()) // No matching row! - require.Nil(t, result.Close()) + tokens, err = a.Tokens(u.ID) + require.Nil(t, err) + require.Equal(t, 1, len(tokens)) + require.Equal(t, token2.Value, tokens[0].Value) + }) } func TestManager_Token_Extend(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - // Try to extend token for user without token - u, err := a.User("ben") - require.Nil(t, err) + // Try to extend token for user without token + u, err := a.User("ben") + require.Nil(t, err) - _, err = a.ChangeToken(u.ID, u.Token, util.String("some label"), util.Time(time.Now().Add(time.Hour))) - require.Equal(t, errNoTokenProvided, err) + _, err = a.ChangeToken(u.ID, u.Token, util.String("some label"), util.Time(time.Now().Add(time.Hour))) + require.Equal(t, errNoTokenProvided, err) - // Create token for user - token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) - require.Nil(t, err) - require.NotEmpty(t, token.Value) + // Create token for user + token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) + require.Nil(t, err) + require.NotEmpty(t, token.Value) - userWithToken, err := a.AuthenticateToken(token.Value) - require.Nil(t, err) + userWithToken, err := a.AuthenticateToken(token.Value) + require.Nil(t, err) - extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour))) - require.Nil(t, err) - require.Equal(t, token.Value, extendedToken.Value) - require.Equal(t, "changed label", extendedToken.Label) - require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix()) - require.True(t, time.Now().Add(99*time.Hour).Unix() < extendedToken.Expires.Unix()) + extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour))) + require.Nil(t, err) + require.Equal(t, token.Value, extendedToken.Value) + require.Equal(t, "changed label", extendedToken.Label) + require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix()) + require.True(t, time.Now().Add(99*time.Hour).Unix() < extendedToken.Expires.Unix()) + }) } func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { // Tests that tokens are automatically deleted when the maximum number of tokens is reached + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - a := newTestManager(t, PermissionDenyAll) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + ben, err := a.User("ben") + require.Nil(t, err) - ben, err := a.User("ben") - require.Nil(t, err) + phil, err := a.User("phil") + require.Nil(t, err) - phil, err := a.User("phil") - require.Nil(t, err) - - // Create 2 tokens for phil - philTokens := make([]string, 0) - token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) - require.Nil(t, err) - require.NotEmpty(t, token.Value) - philTokens = append(philTokens, token.Value) - - token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false) - require.Nil(t, err) - require.NotEmpty(t, token.Value) - philTokens = append(philTokens, token.Value) - - // Create 62 tokens for ben (only 60 allowed!) - baseTime := time.Now().Add(24 * time.Hour) - benTokens := make([]string, 0) - for i := 0; i < 62; i++ { // - token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) + // Create 2 tokens for phil + philTokens := make([]string, 0) + token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) require.Nil(t, err) require.NotEmpty(t, token.Value) - benTokens = append(benTokens, token.Value) + philTokens = append(philTokens, token.Value) - // Manually modify expiry date to avoid sorting issues (this is a hack) - _, err = testDB(a).Exec(`UPDATE user_token SET expires=? WHERE token=?`, baseTime.Add(time.Duration(i)*time.Minute).Unix(), token.Value) + token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false) require.Nil(t, err) - } + require.NotEmpty(t, token.Value) + philTokens = append(philTokens, token.Value) - // Ben: The first 2 tokens should have been wiped and should not work anymore! - _, err = a.AuthenticateToken(benTokens[0]) - require.Equal(t, ErrUnauthenticated, err) + // Create 62 tokens for ben (only 60 allowed!) + baseTime := time.Now().Add(24 * time.Hour) + benTokens := make([]string, 0) + for i := 0; i < 62; i++ { // + token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false) + require.Nil(t, err) + require.NotEmpty(t, token.Value) + benTokens = append(benTokens, token.Value) - _, err = a.AuthenticateToken(benTokens[1]) - require.Equal(t, ErrUnauthenticated, err) + // Manually modify expiry date to avoid sorting issues (this is a hack) + _, err = a.ChangeToken(ben.ID, token.Value, nil, util.Time(baseTime.Add(time.Duration(i)*time.Minute))) + require.Nil(t, err) + } - // Ben: The other tokens should still work - for i := 2; i < 62; i++ { - userWithToken, err := a.AuthenticateToken(benTokens[i]) - require.Nil(t, err, "token[%d]=%s failed", i, benTokens[i]) - require.Equal(t, "ben", userWithToken.Name) - require.Equal(t, benTokens[i], userWithToken.Token) - } + // Ben: The first 2 tokens should have been wiped and should not work anymore! + _, err = a.AuthenticateToken(benTokens[0]) + require.Equal(t, ErrUnauthenticated, err) - // Phil: All tokens should still work - for i := 0; i < 2; i++ { - userWithToken, err := a.AuthenticateToken(philTokens[i]) - require.Nil(t, err, "token[%d]=%s failed", i, philTokens[i]) - require.Equal(t, "phil", userWithToken.Name) - require.Equal(t, philTokens[i], userWithToken.Token) - } + _, err = a.AuthenticateToken(benTokens[1]) + require.Equal(t, ErrUnauthenticated, err) - var benCount int - rows, err := testDB(a).Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, ben.ID) - require.Nil(t, err) - require.True(t, rows.Next()) - require.Nil(t, rows.Scan(&benCount)) - require.Equal(t, 60, benCount) + // Ben: The other tokens should still work + for i := 2; i < 62; i++ { + userWithToken, err := a.AuthenticateToken(benTokens[i]) + require.Nil(t, err, "token[%d]=%s failed", i, benTokens[i]) + require.Equal(t, "ben", userWithToken.Name) + require.Equal(t, benTokens[i], userWithToken.Token) + } - var philCount int - rows, err = testDB(a).Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, phil.ID) - require.Nil(t, err) - require.True(t, rows.Next()) - require.Nil(t, rows.Scan(&philCount)) - require.Equal(t, 2, philCount) + // Phil: All tokens should still work + for i := 0; i < 2; i++ { + userWithToken, err := a.AuthenticateToken(philTokens[i]) + require.Nil(t, err, "token[%d]=%s failed", i, philTokens[i]) + require.Equal(t, "phil", userWithToken.Name) + require.Equal(t, philTokens[i], userWithToken.Token) + } + + benTokensList, err := a.Tokens(ben.ID) + require.Nil(t, err) + require.Equal(t, 60, len(benTokensList)) + + philTokensList, err := a.Tokens(phil.ID) + require.Nil(t, err) + require.Equal(t, 2, len(philTokensList)) + }) } func TestManager_EnqueueStats_ResetStats(t *testing.T) { - filename := filepath.Join(t.TempDir(), "db") - conf := &Config{ - DefaultAccess: PermissionReadWrite, - BcryptCost: bcrypt.MinCost, - QueueWriterInterval: 1500 * time.Millisecond, - } - a := newTestManagerFromStoreConfig(t, filename, conf) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + conf := &Config{ + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 1500 * time.Millisecond, + } + a := newTestManagerFromStoreConfig(t, newStore, conf) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - // Baseline: No messages or emails - u, err := a.User("ben") - require.Nil(t, err) - require.Equal(t, int64(0), u.Stats.Messages) - require.Equal(t, int64(0), u.Stats.Emails) - a.EnqueueUserStats(u.ID, &Stats{ - Messages: 11, - Emails: 2, + // Baseline: No messages or emails + u, err := a.User("ben") + require.Nil(t, err) + require.Equal(t, int64(0), u.Stats.Messages) + require.Equal(t, int64(0), u.Stats.Emails) + a.EnqueueUserStats(u.ID, &Stats{ + Messages: 11, + Emails: 2, + }) + + // Still no change, because it's queued asynchronously + u, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, int64(0), u.Stats.Messages) + require.Equal(t, int64(0), u.Stats.Emails) + + // After 2 seconds they should be persisted + time.Sleep(2 * time.Second) + + u, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, int64(11), u.Stats.Messages) + require.Equal(t, int64(2), u.Stats.Emails) + + // Now reset stats (enqueued stats will be thrown out) + a.EnqueueUserStats(u.ID, &Stats{ + Messages: 99, + Emails: 23, + }) + require.Nil(t, a.ResetStats()) + + u, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, int64(0), u.Stats.Messages) + require.Equal(t, int64(0), u.Stats.Emails) }) - - // Still no change, because it's queued asynchronously - u, err = a.User("ben") - require.Nil(t, err) - require.Equal(t, int64(0), u.Stats.Messages) - require.Equal(t, int64(0), u.Stats.Emails) - - // After 2 seconds they should be persisted - time.Sleep(2 * time.Second) - - u, err = a.User("ben") - require.Nil(t, err) - require.Equal(t, int64(11), u.Stats.Messages) - require.Equal(t, int64(2), u.Stats.Emails) - - // Now reset stats (enqueued stats will be thrown out) - a.EnqueueUserStats(u.ID, &Stats{ - Messages: 99, - Emails: 23, - }) - require.Nil(t, a.ResetStats()) - - u, err = a.User("ben") - require.Nil(t, err) - require.Equal(t, int64(0), u.Stats.Messages) - require.Equal(t, int64(0), u.Stats.Emails) } func TestManager_EnqueueTokenUpdate(t *testing.T) { - filename := filepath.Join(t.TempDir(), "db") - conf := &Config{ - DefaultAccess: PermissionReadWrite, - BcryptCost: bcrypt.MinCost, - QueueWriterInterval: 500 * time.Millisecond, - } - a := newTestManagerFromStoreConfig(t, filename, conf) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + conf := &Config{ + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 500 * time.Millisecond, + } + a := newTestManagerFromStoreConfig(t, newStore, conf) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - // Create user and token - u, err := a.User("ben") - require.Nil(t, err) + // Create user and token + u, err := a.User("ben") + require.Nil(t, err) - token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false) - require.Nil(t, err) + token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false) + require.Nil(t, err) - // Queue token update - a.EnqueueTokenUpdate(token.Value, &TokenUpdate{ - LastAccess: time.Unix(111, 0).UTC(), - LastOrigin: netip.MustParseAddr("1.2.3.3"), + // Queue token update + a.EnqueueTokenUpdate(token.Value, &TokenUpdate{ + LastAccess: time.Unix(111, 0).UTC(), + LastOrigin: netip.MustParseAddr("1.2.3.3"), + }) + + // Token has not changed yet. + token2, err := a.Token(u.ID, token.Value) + require.Nil(t, err) + require.Equal(t, token.LastAccess.Unix(), token2.LastAccess.Unix()) + require.Equal(t, token.LastOrigin, token2.LastOrigin) + + // After a second or so they should be persisted + time.Sleep(time.Second) + + token3, err := a.Token(u.ID, token.Value) + require.Nil(t, err) + require.Equal(t, time.Unix(111, 0).UTC().Unix(), token3.LastAccess.Unix()) + require.Equal(t, netip.MustParseAddr("1.2.3.3"), token3.LastOrigin) }) - - // Token has not changed yet. - token2, err := a.Token(u.ID, token.Value) - require.Nil(t, err) - require.Equal(t, token.LastAccess.Unix(), token2.LastAccess.Unix()) - require.Equal(t, token.LastOrigin, token2.LastOrigin) - - // After a second or so they should be persisted - time.Sleep(time.Second) - - token3, err := a.Token(u.ID, token.Value) - require.Nil(t, err) - require.Equal(t, time.Unix(111, 0).UTC().Unix(), token3.LastAccess.Unix()) - require.Equal(t, netip.MustParseAddr("1.2.3.3"), token3.LastOrigin) } func TestManager_ChangeSettings(t *testing.T) { - filename := filepath.Join(t.TempDir(), "db") - conf := &Config{ - DefaultAccess: PermissionReadWrite, - BcryptCost: bcrypt.MinCost, - QueueWriterInterval: 1500 * time.Millisecond, - } - a := newTestManagerFromStoreConfig(t, filename, conf) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + conf := &Config{ + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 1500 * time.Millisecond, + } + a := newTestManagerFromStoreConfig(t, newStore, conf) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - // No settings - u, err := a.User("ben") - require.Nil(t, err) - require.Nil(t, u.Prefs.Subscriptions) - require.Nil(t, u.Prefs.Notification) - require.Nil(t, u.Prefs.Language) + // No settings + u, err := a.User("ben") + require.Nil(t, err) + require.Nil(t, u.Prefs.Subscriptions) + require.Nil(t, u.Prefs.Notification) + require.Nil(t, u.Prefs.Language) - // Save with new settings - prefs := &Prefs{ - Language: util.String("de"), - Notification: &NotificationPrefs{ - Sound: util.String("ding"), - MinPriority: util.Int(2), - }, - Subscriptions: []*Subscription{ - { - BaseURL: "https://ntfy.sh", - Topic: "mytopic", - DisplayName: util.String("My Topic"), + // Save with new settings + prefs := &Prefs{ + Language: util.String("de"), + Notification: &NotificationPrefs{ + Sound: util.String("ding"), + MinPriority: util.Int(2), }, - }, - } - require.Nil(t, a.ChangeSettings(u.ID, prefs)) + Subscriptions: []*Subscription{ + { + BaseURL: "https://ntfy.sh", + Topic: "mytopic", + DisplayName: util.String("My Topic"), + }, + }, + } + require.Nil(t, a.ChangeSettings(u.ID, prefs)) - // Read again - u, err = a.User("ben") - require.Nil(t, err) - require.Equal(t, util.String("de"), u.Prefs.Language) - require.Equal(t, util.String("ding"), u.Prefs.Notification.Sound) - require.Equal(t, util.Int(2), u.Prefs.Notification.MinPriority) - require.Nil(t, u.Prefs.Notification.DeleteAfter) - require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL) - require.Equal(t, "mytopic", u.Prefs.Subscriptions[0].Topic) - require.Equal(t, util.String("My Topic"), u.Prefs.Subscriptions[0].DisplayName) + // Read again + u, err = a.User("ben") + require.Nil(t, err) + require.Equal(t, util.String("de"), u.Prefs.Language) + require.Equal(t, util.String("ding"), u.Prefs.Notification.Sound) + require.Equal(t, util.Int(2), u.Prefs.Notification.MinPriority) + require.Nil(t, u.Prefs.Notification.DeleteAfter) + require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL) + require.Equal(t, "mytopic", u.Prefs.Subscriptions[0].Topic) + require.Equal(t, util.String("My Topic"), u.Prefs.Subscriptions[0].DisplayName) + }) } func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) - // Create tier and user - require.Nil(t, a.AddTier(&Tier{ - Code: "supporter", - Name: "Supporter", - MessageLimit: 1, - MessageExpiryDuration: time.Second, - EmailLimit: 1, - ReservationLimit: 1, - AttachmentFileSizeLimit: 1, - AttachmentTotalSizeLimit: 1, - AttachmentExpiryDuration: time.Second, - AttachmentBandwidthLimit: 1, - StripeMonthlyPriceID: "price_1", - })) - require.Nil(t, a.AddTier(&Tier{ - Code: "pro", - Name: "Pro", - MessageLimit: 123, - MessageExpiryDuration: 86400 * time.Second, - EmailLimit: 32, - ReservationLimit: 2, - AttachmentFileSizeLimit: 1231231, - AttachmentTotalSizeLimit: 123123, - AttachmentExpiryDuration: 10800 * time.Second, - AttachmentBandwidthLimit: 21474836480, - StripeMonthlyPriceID: "price_2", - })) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - require.Nil(t, a.ChangeTier("phil", "pro")) + // Create tier and user + require.Nil(t, a.AddTier(&Tier{ + Code: "supporter", + Name: "Supporter", + MessageLimit: 1, + MessageExpiryDuration: time.Second, + EmailLimit: 1, + ReservationLimit: 1, + AttachmentFileSizeLimit: 1, + AttachmentTotalSizeLimit: 1, + AttachmentExpiryDuration: time.Second, + AttachmentBandwidthLimit: 1, + StripeMonthlyPriceID: "price_1", + })) + require.Nil(t, a.AddTier(&Tier{ + Code: "pro", + Name: "Pro", + MessageLimit: 123, + MessageExpiryDuration: 86400 * time.Second, + EmailLimit: 32, + ReservationLimit: 2, + AttachmentFileSizeLimit: 1231231, + AttachmentTotalSizeLimit: 123123, + AttachmentExpiryDuration: 10800 * time.Second, + AttachmentBandwidthLimit: 21474836480, + StripeMonthlyPriceID: "price_2", + })) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.ChangeTier("phil", "pro")) - ti, err := a.Tier("pro") - require.Nil(t, err) + ti, err := a.Tier("pro") + require.Nil(t, err) - u, err := a.User("phil") - require.Nil(t, err) + u, err := a.User("phil") + require.Nil(t, err) - // These are populated by different SQL queries - require.Equal(t, ti, u.Tier) + // These are populated by different SQL queries + require.Equal(t, ti, u.Tier) - // Fields - require.True(t, strings.HasPrefix(ti.ID, "ti_")) - require.Equal(t, "pro", ti.Code) - require.Equal(t, "Pro", ti.Name) - require.Equal(t, int64(123), ti.MessageLimit) - require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration) - require.Equal(t, int64(32), ti.EmailLimit) - require.Equal(t, int64(2), ti.ReservationLimit) - require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit) - require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit) - require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration) - require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit) - require.Equal(t, "price_2", ti.StripeMonthlyPriceID) + // Fields + require.True(t, strings.HasPrefix(ti.ID, "ti_")) + require.Equal(t, "pro", ti.Code) + require.Equal(t, "Pro", ti.Name) + require.Equal(t, int64(123), ti.MessageLimit) + require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration) + require.Equal(t, int64(32), ti.EmailLimit) + require.Equal(t, int64(2), ti.ReservationLimit) + require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit) + require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit) + require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration) + require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit) + require.Equal(t, "price_2", ti.StripeMonthlyPriceID) - // Update tier - ti.EmailLimit = 999999 - require.Nil(t, a.UpdateTier(ti)) + // Update tier + ti.EmailLimit = 999999 + require.Nil(t, a.UpdateTier(ti)) - // List tiers - tiers, err := a.Tiers() - require.Nil(t, err) - require.Equal(t, 2, len(tiers)) + // List tiers + tiers, err := a.Tiers() + require.Nil(t, err) + require.Equal(t, 2, len(tiers)) - ti = tiers[0] - require.Equal(t, "supporter", ti.Code) - require.Equal(t, "Supporter", ti.Name) - require.Equal(t, int64(1), ti.MessageLimit) - require.Equal(t, time.Second, ti.MessageExpiryDuration) - require.Equal(t, int64(1), ti.EmailLimit) - require.Equal(t, int64(1), ti.ReservationLimit) - require.Equal(t, int64(1), ti.AttachmentFileSizeLimit) - require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit) - require.Equal(t, time.Second, ti.AttachmentExpiryDuration) - require.Equal(t, int64(1), ti.AttachmentBandwidthLimit) - require.Equal(t, "price_1", ti.StripeMonthlyPriceID) + ti = tiers[0] + require.Equal(t, "supporter", ti.Code) + require.Equal(t, "Supporter", ti.Name) + require.Equal(t, int64(1), ti.MessageLimit) + require.Equal(t, time.Second, ti.MessageExpiryDuration) + require.Equal(t, int64(1), ti.EmailLimit) + require.Equal(t, int64(1), ti.ReservationLimit) + require.Equal(t, int64(1), ti.AttachmentFileSizeLimit) + require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit) + require.Equal(t, time.Second, ti.AttachmentExpiryDuration) + require.Equal(t, int64(1), ti.AttachmentBandwidthLimit) + require.Equal(t, "price_1", ti.StripeMonthlyPriceID) - ti = tiers[1] - require.Equal(t, "pro", ti.Code) - require.Equal(t, "Pro", ti.Name) - require.Equal(t, int64(123), ti.MessageLimit) - require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration) - require.Equal(t, int64(999999), ti.EmailLimit) // Updatedd! - require.Equal(t, int64(2), ti.ReservationLimit) - require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit) - require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit) - require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration) - require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit) - require.Equal(t, "price_2", ti.StripeMonthlyPriceID) + ti = tiers[1] + require.Equal(t, "pro", ti.Code) + require.Equal(t, "Pro", ti.Name) + require.Equal(t, int64(123), ti.MessageLimit) + require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration) + require.Equal(t, int64(999999), ti.EmailLimit) // Updatedd! + require.Equal(t, int64(2), ti.ReservationLimit) + require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit) + require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit) + require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration) + require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit) + require.Equal(t, "price_2", ti.StripeMonthlyPriceID) - ti, err = a.TierByStripePrice("price_1") - require.Nil(t, err) - require.Equal(t, "supporter", ti.Code) - require.Equal(t, "Supporter", ti.Name) - require.Equal(t, int64(1), ti.MessageLimit) - require.Equal(t, time.Second, ti.MessageExpiryDuration) - require.Equal(t, int64(1), ti.EmailLimit) - require.Equal(t, int64(1), ti.ReservationLimit) - require.Equal(t, int64(1), ti.AttachmentFileSizeLimit) - require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit) - require.Equal(t, time.Second, ti.AttachmentExpiryDuration) - require.Equal(t, int64(1), ti.AttachmentBandwidthLimit) - require.Equal(t, "price_1", ti.StripeMonthlyPriceID) + ti, err = a.TierByStripePrice("price_1") + require.Nil(t, err) + require.Equal(t, "supporter", ti.Code) + require.Equal(t, "Supporter", ti.Name) + require.Equal(t, int64(1), ti.MessageLimit) + require.Equal(t, time.Second, ti.MessageExpiryDuration) + require.Equal(t, int64(1), ti.EmailLimit) + require.Equal(t, int64(1), ti.ReservationLimit) + require.Equal(t, int64(1), ti.AttachmentFileSizeLimit) + require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit) + require.Equal(t, time.Second, ti.AttachmentExpiryDuration) + require.Equal(t, int64(1), ti.AttachmentBandwidthLimit) + require.Equal(t, "price_1", ti.StripeMonthlyPriceID) - // Cannot remove tier, since user has this tier - require.Error(t, a.RemoveTier("pro")) + // Cannot remove tier, since user has this tier + require.Error(t, a.RemoveTier("pro")) - // CAN remove this tier - require.Nil(t, a.RemoveTier("supporter")) + // CAN remove this tier + require.Nil(t, a.RemoveTier("supporter")) - tiers, err = a.Tiers() - require.Nil(t, err) - require.Equal(t, 1, len(tiers)) - require.Equal(t, "pro", tiers[0].Code) - require.Equal(t, "pro", tiers[0].Code) + tiers, err = a.Tiers() + require.Nil(t, err) + require.Equal(t, 1, len(tiers)) + require.Equal(t, "pro", tiers[0].Code) + require.Equal(t, "pro", tiers[0].Code) + }) } func TestAccount_Tier_Create_With_ID(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) - require.Nil(t, a.AddTier(&Tier{ - ID: "ti_123", - Code: "pro", - })) + require.Nil(t, a.AddTier(&Tier{ + ID: "ti_123", + Code: "pro", + })) - ti, err := a.Tier("pro") - require.Nil(t, err) - require.Equal(t, "ti_123", ti.ID) + ti, err := a.Tier("pro") + require.Nil(t, err) + require.Equal(t, "ti_123", ti.ID) + }) } func TestManager_Tier_Change_And_Reset(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) - // Create tier and user - require.Nil(t, a.AddTier(&Tier{ - Code: "supporter", - Name: "Supporter", - ReservationLimit: 3, - })) - require.Nil(t, a.AddTier(&Tier{ - Code: "pro", - Name: "Pro", - ReservationLimit: 4, - })) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - require.Nil(t, a.ChangeTier("phil", "pro")) + // Create tier and user + require.Nil(t, a.AddTier(&Tier{ + Code: "supporter", + Name: "Supporter", + ReservationLimit: 3, + })) + require.Nil(t, a.AddTier(&Tier{ + Code: "pro", + Name: "Pro", + ReservationLimit: 4, + })) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.ChangeTier("phil", "pro")) - // Add 10 reservations (pro tier allows that) - for i := 0; i < 4; i++ { - require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite)) - } + // Add 10 reservations (pro tier allows that) + for i := 0; i < 4; i++ { + require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite)) + } - // Downgrading will not work (too many reservations) - require.Equal(t, ErrTooManyReservations, a.ChangeTier("phil", "supporter")) + // Downgrading will not work (too many reservations) + require.Equal(t, ErrTooManyReservations, a.ChangeTier("phil", "supporter")) - // Downgrade after removing a reservation - require.Nil(t, a.RemoveReservations("phil", "topic0")) - require.Nil(t, a.ChangeTier("phil", "supporter")) + // Downgrade after removing a reservation + require.Nil(t, a.RemoveReservations("phil", "topic0")) + require.Nil(t, a.ChangeTier("phil", "supporter")) - // Resetting will not work (too many reservations) - require.Equal(t, ErrTooManyReservations, a.ResetTier("phil")) + // Resetting will not work (too many reservations) + require.Equal(t, ErrTooManyReservations, a.ResetTier("phil")) - // Resetting after removing all reservations - require.Nil(t, a.RemoveReservations("phil", "topic1", "topic2", "topic3")) - require.Nil(t, a.ResetTier("phil")) + // Resetting after removing all reservations + require.Nil(t, a.RemoveReservations("phil", "topic1", "topic2", "topic3")) + require.Nil(t, a.ResetTier("phil")) + }) } func TestUser_PhoneNumberAddListRemove(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) - phoneNumbers, err := a.PhoneNumbers(phil.ID) - require.Nil(t, err) - require.Equal(t, 1, len(phoneNumbers)) - require.Equal(t, "+1234567890", phoneNumbers[0]) + phoneNumbers, err := a.PhoneNumbers(phil.ID) + require.Nil(t, err) + require.Equal(t, 1, len(phoneNumbers)) + require.Equal(t, "+1234567890", phoneNumbers[0]) - require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890")) - phoneNumbers, err = a.PhoneNumbers(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(phoneNumbers)) + require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890")) + phoneNumbers, err = a.PhoneNumbers(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(phoneNumbers)) - // Paranoia check: We do NOT want to keep phone numbers in there - rows, err := testDB(a).Query(`SELECT * FROM user_phone`) - require.Nil(t, err) - require.False(t, rows.Next()) - require.Nil(t, rows.Close()) + // Paranoia check: We do NOT want to keep phone numbers in there + rows, err := testDB(a).Query(`SELECT * FROM user_phone`) + require.Nil(t, err) + require.False(t, rows.Next()) + require.Nil(t, rows.Close()) + }) } func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - ben, err := a.User("ben") - require.Nil(t, err) - require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) - require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890")) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) + require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890")) + }) } func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) { - f := filepath.Join(t.TempDir(), "user.db") - a := newTestManagerFromFile(t, f, "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) - require.Nil(t, a.AllowAccess(Everyone, "*_", PermissionRead)) - require.Nil(t, a.AllowAccess(Everyone, "__*_", PermissionRead)) - require.Nil(t, a.Authorize(nil, "allowed_", PermissionRead)) - require.Nil(t, a.Authorize(nil, "__allowed_", PermissionRead)) - require.Nil(t, a.Authorize(nil, "_allowed_", PermissionRead)) // The "%" in "%\_" matches the first "_" - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "notallowed", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "_notallowed", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "__notallowed", PermissionRead)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AllowAccess(Everyone, "*_", PermissionRead)) + require.Nil(t, a.AllowAccess(Everyone, "__*_", PermissionRead)) + require.Nil(t, a.Authorize(nil, "allowed_", PermissionRead)) + require.Nil(t, a.Authorize(nil, "__allowed_", PermissionRead)) + require.Nil(t, a.Authorize(nil, "_allowed_", PermissionRead)) // The "%" in "%\_" matches the first "_" + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "notallowed", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "_notallowed", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "__notallowed", PermissionRead)) + }) } func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { - f := filepath.Join(t.TempDir(), "user.db") - a := newTestManagerFromFile(t, f, "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) - require.Nil(t, a.AllowAccess(Everyone, "mytopic_", PermissionReadWrite)) - require.Nil(t, a.Authorize(nil, "mytopic_", PermissionRead)) - require.Nil(t, a.Authorize(nil, "mytopic_", PermissionWrite)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionRead)) - require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite)) + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + a := newTestManager(t, newStore, PermissionDenyAll) + require.Nil(t, a.AllowAccess(Everyone, "mytopic_", PermissionReadWrite)) + require.Nil(t, a.Authorize(nil, "mytopic_", PermissionRead)) + require.Nil(t, a.Authorize(nil, "mytopic_", PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite)) + }) } func TestManager_WithProvisionedUsers(t *testing.T) { - f := filepath.Join(t.TempDir(), "user.db") - conf := &Config{ - DefaultAccess: PermissionReadWrite, - ProvisionEnabled: true, - Users: []*User{ - {Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, - {Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, - }, - Access: map[string][]*Grant{ - "philuser": { - {TopicPattern: "stats", Permission: PermissionReadWrite}, - {TopicPattern: "secret", Permission: PermissionRead}, + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + conf := &Config{ + DefaultAccess: PermissionReadWrite, + ProvisionEnabled: true, + Users: []*User{ + {Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + {Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, }, - }, - Tokens: map[string][]*Token{ - "philuser": { - {Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token"}, + Access: map[string][]*Grant{ + "philuser": { + {TopicPattern: "stats", Permission: PermissionReadWrite}, + {TopicPattern: "secret", Permission: PermissionRead}, + }, }, - }, - } - a := newTestManagerFromStoreConfig(t, f, conf) + Tokens: map[string][]*Token{ + "philuser": { + {Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token"}, + }, + }, + } + a := newTestManagerFromStoreConfig(t, newStore, conf) - // Manually add user - require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false)) + // Manually add user + require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false)) - // Check that the provisioned users are there - users, err := a.Users() - require.Nil(t, err) - require.Len(t, users, 4) - require.Equal(t, "philadmin", users[0].Name) - require.Equal(t, RoleAdmin, users[0].Role) - require.Equal(t, "philmanual", users[1].Name) - require.Equal(t, RoleUser, users[1].Role) - require.Equal(t, "philuser", users[2].Name) - require.Equal(t, RoleUser, users[2].Role) - require.Equal(t, "*", users[3].Name) - provisionedUserID := users[2].ID // "philuser" is the provisioned user + // Check that the provisioned users are there + users, err := a.Users() + require.Nil(t, err) + require.Len(t, users, 4) + require.Equal(t, "philadmin", users[0].Name) + require.Equal(t, RoleAdmin, users[0].Role) + require.Equal(t, "philmanual", users[1].Name) + require.Equal(t, RoleUser, users[1].Role) + require.Equal(t, "philuser", users[2].Name) + require.Equal(t, RoleUser, users[2].Role) + require.Equal(t, "*", users[3].Name) + provisionedUserID := users[2].ID // "philuser" is the provisioned user - grants, err := a.Grants("philuser") - require.Nil(t, err) - require.Equal(t, 2, len(grants)) - require.Equal(t, "secret", grants[0].TopicPattern) - require.Equal(t, PermissionRead, grants[0].Permission) - require.Equal(t, "stats", grants[1].TopicPattern) - require.Equal(t, PermissionReadWrite, grants[1].Permission) + grants, err := a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 2, len(grants)) + require.Equal(t, "secret", grants[0].TopicPattern) + require.Equal(t, PermissionRead, grants[0].Permission) + require.Equal(t, "stats", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) - tokens, err := a.Tokens(provisionedUserID) - require.Nil(t, err) - require.Equal(t, 1, len(tokens)) - require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value) - require.Equal(t, "Alerts token", tokens[0].Label) - require.True(t, tokens[0].Provisioned) + tokens, err := a.Tokens(provisionedUserID) + require.Nil(t, err) + require.Equal(t, 1, len(tokens)) + require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value) + require.Equal(t, "Alerts token", tokens[0].Label) + require.True(t, tokens[0].Provisioned) - // Update the token last access time and origin (so we can check that it is persisted) - lastAccessTime := time.Now().Add(time.Hour) - lastOrigin := netip.MustParseAddr("1.1.9.9") - err = a.store.UpdateTokenLastAccess(tokens[0].Value, lastAccessTime, lastOrigin) - require.Nil(t, err) + // Update the token last access time and origin (so we can check that it is persisted) + lastAccessTime := time.Now().Add(time.Hour) + lastOrigin := netip.MustParseAddr("1.1.9.9") + err = a.store.UpdateTokenLastAccess(tokens[0].Value, lastAccessTime, lastOrigin) + require.Nil(t, err) - // Re-open the DB (second app start) - require.Nil(t, a.Close()) - conf.Users = []*User{ - {Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, - } - conf.Access = map[string][]*Grant{ - "philuser": { - {TopicPattern: "stats12", Permission: PermissionReadWrite}, - {TopicPattern: "secret12", Permission: PermissionRead}, - }, - } - conf.Tokens = map[string][]*Token{ - "philuser": { - {Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token updated"}, - {Value: "tk_u48wqendnkx9er21pqqcadlytbutx", Label: "Another token"}, - }, - } - a = newTestManagerFromStoreConfig(t, f, conf) + // Re-open the DB (second app start) + require.Nil(t, a.Close()) + conf.Users = []*User{ + {Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + } + conf.Access = map[string][]*Grant{ + "philuser": { + {TopicPattern: "stats12", Permission: PermissionReadWrite}, + {TopicPattern: "secret12", Permission: PermissionRead}, + }, + } + conf.Tokens = map[string][]*Token{ + "philuser": { + {Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token updated"}, + {Value: "tk_u48wqendnkx9er21pqqcadlytbutx", Label: "Another token"}, + }, + } + a = newTestManagerFromStoreConfig(t, newStore, conf) - // Check that the provisioned users are there - users, err = a.Users() - require.Nil(t, err) - require.Len(t, users, 3) - require.Equal(t, "philmanual", users[0].Name) - require.Equal(t, "philuser", users[1].Name) - require.Equal(t, RoleUser, users[1].Role) - require.Equal(t, RoleUser, users[0].Role) - require.Equal(t, "*", users[2].Name) + // Check that the provisioned users are there + users, err = a.Users() + require.Nil(t, err) + require.Len(t, users, 3) + require.Equal(t, "philmanual", users[0].Name) + require.Equal(t, "philuser", users[1].Name) + require.Equal(t, RoleUser, users[1].Role) + require.Equal(t, RoleUser, users[0].Role) + require.Equal(t, "*", users[2].Name) - grants, err = a.Grants("philuser") - require.Nil(t, err) - require.Equal(t, 2, len(grants)) - require.Equal(t, "secret12", grants[0].TopicPattern) - require.Equal(t, PermissionRead, grants[0].Permission) - require.Equal(t, "stats12", grants[1].TopicPattern) - require.Equal(t, PermissionReadWrite, grants[1].Permission) + grants, err = a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 2, len(grants)) + require.Equal(t, "secret12", grants[0].TopicPattern) + require.Equal(t, PermissionRead, grants[0].Permission) + require.Equal(t, "stats12", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) - tokens, err = a.Tokens(provisionedUserID) - require.Nil(t, err) - require.Equal(t, 2, len(tokens)) - require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value) - require.Equal(t, "Alerts token updated", tokens[0].Label) - require.Equal(t, lastAccessTime.Unix(), tokens[0].LastAccess.Unix()) - require.Equal(t, lastOrigin, tokens[0].LastOrigin) - require.True(t, tokens[0].Provisioned) - require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value) - require.Equal(t, "Another token", tokens[1].Label) + tokens, err = a.Tokens(provisionedUserID) + require.Nil(t, err) + require.Equal(t, 2, len(tokens)) + require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value) + require.Equal(t, "Alerts token updated", tokens[0].Label) + require.Equal(t, lastAccessTime.Unix(), tokens[0].LastAccess.Unix()) + require.Equal(t, lastOrigin, tokens[0].LastOrigin) + require.True(t, tokens[0].Provisioned) + require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value) + require.Equal(t, "Another token", tokens[1].Label) - // Try changing provisioned user's password - require.Error(t, a.ChangePassword("philuser", "new-pass", false)) + // Try changing provisioned user's password + require.Error(t, a.ChangePassword("philuser", "new-pass", false)) - // Re-open the DB again (third app start) - require.Nil(t, a.Close()) - conf.Users = []*User{} - conf.Access = map[string][]*Grant{} - conf.Tokens = map[string][]*Token{} - a = newTestManagerFromStoreConfig(t, f, conf) + // Re-open the DB again (third app start) + require.Nil(t, a.Close()) + conf.Users = []*User{} + conf.Access = map[string][]*Grant{} + conf.Tokens = map[string][]*Token{} + a = newTestManagerFromStoreConfig(t, newStore, conf) - // Check that the provisioned users are all gone - users, err = a.Users() - require.Nil(t, err) - require.Len(t, users, 2) + // Check that the provisioned users are all gone + users, err = a.Users() + require.Nil(t, err) + require.Len(t, users, 2) - require.Equal(t, "philmanual", users[0].Name) - require.Equal(t, RoleUser, users[0].Role) - require.Equal(t, "*", users[1].Name) + require.Equal(t, "philmanual", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) + require.Equal(t, "*", users[1].Name) - grants, err = a.Grants("philuser") - require.Nil(t, err) - require.Equal(t, 0, len(grants)) + grants, err = a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 0, len(grants)) - tokens, err = a.Tokens(provisionedUserID) - require.Nil(t, err) - require.Equal(t, 0, len(tokens)) + tokens, err = a.Tokens(provisionedUserID) + require.Nil(t, err) + require.Equal(t, 0, len(tokens)) - var count int - testDB(a).QueryRow("SELECT COUNT(*) FROM user WHERE provisioned = 1").Scan(&count) - require.Equal(t, 0, count) - testDB(a).QueryRow("SELECT COUNT(*) FROM user_access WHERE provisioned = 1").Scan(&count) - require.Equal(t, 0, count) - testDB(a).QueryRow("SELECT COUNT(*) FROM user_token WHERE provisioned = 1").Scan(&count) + // Verify no provisioned data remains + for _, u := range users { + require.False(t, u.Provisioned) + userGrants, err := a.Grants(u.Name) + require.Nil(t, err) + for _, g := range userGrants { + require.False(t, g.Provisioned) + } + userTokens, err := a.Tokens(u.ID) + require.Nil(t, err) + for _, tk := range userTokens { + require.False(t, tk.Provisioned) + } + } + }) } func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { - f := filepath.Join(t.TempDir(), "user.db") - conf := &Config{ - DefaultAccess: PermissionReadWrite, - ProvisionEnabled: true, - Users: []*User{}, - Access: map[string][]*Grant{ - Everyone: { - {TopicPattern: "food", Permission: PermissionRead}, + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + conf := &Config{ + DefaultAccess: PermissionReadWrite, + ProvisionEnabled: true, + Users: []*User{}, + Access: map[string][]*Grant{ + Everyone: { + {TopicPattern: "food", Permission: PermissionRead}, + }, }, - }, - } - a := newTestManagerFromStoreConfig(t, f, conf) + } + a := newTestManagerFromStoreConfig(t, newStore, conf) - // Manually add user - require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false)) - require.Nil(t, a.AllowAccess("philuser", "stats", PermissionReadWrite)) - require.Nil(t, a.AllowAccess("philuser", "food", PermissionReadWrite)) + // Manually add user + require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false)) + require.Nil(t, a.AllowAccess("philuser", "stats", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("philuser", "food", PermissionReadWrite)) - users, err := a.Users() - require.Nil(t, err) - require.Len(t, users, 2) - require.Equal(t, "philuser", users[0].Name) - require.Equal(t, RoleUser, users[0].Role) - require.False(t, users[0].Provisioned) // Manually added + users, err := a.Users() + require.Nil(t, err) + require.Len(t, users, 2) + require.Equal(t, "philuser", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) + require.False(t, users[0].Provisioned) // Manually added - grants, err := a.Grants("philuser") - require.Nil(t, err) - require.Equal(t, 2, len(grants)) - require.Equal(t, "stats", grants[0].TopicPattern) - require.Equal(t, PermissionReadWrite, grants[0].Permission) - require.False(t, grants[0].Provisioned) // Manually added - require.Equal(t, "food", grants[1].TopicPattern) - require.Equal(t, PermissionReadWrite, grants[1].Permission) - require.False(t, grants[1].Provisioned) // Manually added + grants, err := a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 2, len(grants)) + require.Equal(t, "stats", grants[0].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[0].Permission) + require.False(t, grants[0].Provisioned) // Manually added + require.Equal(t, "food", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) + require.False(t, grants[1].Provisioned) // Manually added - grants, err = a.Grants(Everyone) - require.Nil(t, err) - require.Equal(t, 1, len(grants)) - require.Equal(t, "food", grants[0].TopicPattern) - require.Equal(t, PermissionRead, grants[0].Permission) - require.True(t, grants[0].Provisioned) // Provisioned entry + grants, err = a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, 1, len(grants)) + require.Equal(t, "food", grants[0].TopicPattern) + require.Equal(t, PermissionRead, grants[0].Permission) + require.True(t, grants[0].Provisioned) // Provisioned entry - // Re-open the DB (second app start) - require.Nil(t, a.Close()) - conf.Users = []*User{ - {Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, - } - conf.Access = map[string][]*Grant{ - "philuser": { - {TopicPattern: "stats", Permission: PermissionReadWrite}, - }, - } - a = newTestManagerFromStoreConfig(t, f, conf) + // Re-open the DB (second app start) + require.Nil(t, a.Close()) + conf.Users = []*User{ + {Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + } + conf.Access = map[string][]*Grant{ + "philuser": { + {TopicPattern: "stats", Permission: PermissionReadWrite}, + }, + } + a = newTestManagerFromStoreConfig(t, newStore, conf) - // Check that the user was "upgraded" to a provisioned user - users, err = a.Users() - require.Nil(t, err) - require.Len(t, users, 2) - require.Equal(t, "philuser", users[0].Name) - require.Equal(t, RoleUser, users[0].Role) - require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) - require.True(t, users[0].Provisioned) // Updated to provisioned! + // Check that the user was "upgraded" to a provisioned user + users, err = a.Users() + require.Nil(t, err) + require.Len(t, users, 2) + require.Equal(t, "philuser", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) + require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) + require.True(t, users[0].Provisioned) // Updated to provisioned! - grants, err = a.Grants("philuser") - require.Nil(t, err) - require.Equal(t, 2, len(grants)) - require.Equal(t, "stats", grants[0].TopicPattern) - require.Equal(t, PermissionReadWrite, grants[0].Permission) - require.True(t, grants[0].Provisioned) // Updated to provisioned! - require.Equal(t, "food", grants[1].TopicPattern) - require.Equal(t, PermissionReadWrite, grants[1].Permission) - require.False(t, grants[1].Provisioned) // Manually added grants stay! + grants, err = a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 2, len(grants)) + require.Equal(t, "stats", grants[0].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[0].Permission) + require.True(t, grants[0].Provisioned) // Updated to provisioned! + require.Equal(t, "food", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) + require.False(t, grants[1].Provisioned) // Manually added grants stay! - grants, err = a.Grants(Everyone) - require.Nil(t, err) - require.Empty(t, grants) + grants, err = a.Grants(Everyone) + require.Nil(t, err) + require.Empty(t, grants) + }) } func TestToFromSQLWildcard(t *testing.T) { @@ -1340,7 +1440,7 @@ func TestMigrationFrom1(t *testing.T) { role TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS access ( - user TEXT NOT NULL, + user TEXT NOT NULL, topic TEXT NOT NULL, read INT NOT NULL, write INT NOT NULL, @@ -1351,7 +1451,7 @@ func TestMigrationFrom1(t *testing.T) { version INT NOT NULL ); INSERT INTO schemaVersion (id, version) VALUES (1, 1); - COMMIT; + COMMIT; `) require.Nil(t, err) @@ -1363,7 +1463,7 @@ func TestMigrationFrom1(t *testing.T) { INSERT INTO access (user, topic, read, write) VALUES ('ben', 'stats', 1, 1); INSERT INTO access (user, topic, read, write) VALUES ('ben', 'secret', 1, 0); INSERT INTO access (user, topic, read, write) VALUES ('*', 'stats', 1, 0); - COMMIT; + COMMIT; `) require.Nil(t, err) @@ -1495,7 +1595,7 @@ func TestMigrationFrom4(t *testing.T) { INSERT INTO user (id, user, pass, role, sync_topic, created) VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH()) ON CONFLICT (id) DO NOTHING; - INSERT INTO schemaVersion (id, version) VALUES (1, 4); + INSERT INTO schemaVersion (id, version) VALUES (1, 4); COMMIT; `) require.Nil(t, err) @@ -1506,7 +1606,7 @@ func TestMigrationFrom4(t *testing.T) { 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); - COMMIT; + COMMIT; `) require.Nil(t, err) @@ -1568,8 +1668,16 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) { require.Nil(t, rows.Close()) } -func newTestManager(t *testing.T, defaultAccess Permission) *Manager { - return newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", defaultAccess, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) +func newTestManager(t *testing.T, newStore newStoreFunc, defaultAccess Permission) *Manager { + store := newStore() + a, err := NewManager(store, &Config{ + DefaultAccess: defaultAccess, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: DefaultUserStatsQueueWriterInterval, + }) + require.Nil(t, err) + t.Cleanup(func() { a.Close() }) + return a } func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager { @@ -1585,14 +1693,14 @@ func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defau return a } +func newTestManagerFromStoreConfig(t *testing.T, newStore newStoreFunc, conf *Config) *Manager { + store := newStore() + a, err := NewManager(store, conf) + require.Nil(t, err) + t.Cleanup(func() { a.Close() }) + return a +} + func testDB(a *Manager) *sql.DB { return a.store.(*commonStore).db } - -func newTestManagerFromStoreConfig(t *testing.T, filename string, conf *Config) *Manager { - store, err := NewSQLiteStore(filename, "") - require.Nil(t, err) - a, err := NewManager(store, conf) - require.Nil(t, err) - return a -} diff --git a/user/store.go b/user/store.go index bde62658..4dbc2bad 100644 --- a/user/store.go +++ b/user/store.go @@ -19,6 +19,7 @@ type Store interface { User(username string) (*User, error) UserByToken(token string) (*User, error) UserByStripeCustomer(customerID string) (*User, error) + UserIDByUsername(username string) (string, error) Users() ([]*User, error) UsersCount() (int64, error) AddUser(username, hash string, role Role, provisioned bool) error @@ -33,6 +34,7 @@ type Store interface { ResetTier(username string) error UpdateStats(userID string, stats *Stats) error ResetStats() error + // Token operations CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, provisioned bool) (*Token, error) Token(userID, token string) (*Token, error) @@ -45,6 +47,7 @@ type Store interface { RemoveExpiredTokens() error TokenCount(userID string) (int, error) RemoveExcessTokens(userID string, maxCount int) error + // Access operations AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) AllGrants() (map[string][]Grant, error) @@ -57,6 +60,7 @@ type Store interface { ReservationsCount(username string) (int64, error) ReservationOwner(topic string) (string, error) OtherAccessCount(username, topic string) (int, error) + // Tier operations AddTier(tier *Tier) error UpdateTier(tier *Tier) error @@ -64,15 +68,14 @@ type Store interface { Tiers() ([]*Tier, error) Tier(code string) (*Tier, error) TierByStripePrice(priceID string) (*Tier, error) + // Phone operations PhoneNumbers(userID string) ([]string, error) AddPhoneNumber(userID, phoneNumber string) error RemovePhoneNumber(userID, phoneNumber string) error - // Billing + + // Other stuff ChangeBilling(username string, billing *Billing) error - // Internal helpers - UserIDByUsername(username string) (string, error) - // System Close() error } From 652b2097add2710a85616b5c2af5ac65a7c72518 Mon Sep 17 00:00:00 2001 From: Yaron Shahrabani Date: Tue, 17 Feb 2026 12:31:20 +0100 Subject: [PATCH 194/548] Translated using Weblate (Hebrew) Currently translated at 17.4% (71 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/he/ --- web/public/static/langs/he.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/he.json b/web/public/static/langs/he.json index 4674c993..9e46e071 100644 --- a/web/public/static/langs/he.json +++ b/web/public/static/langs/he.json @@ -48,5 +48,26 @@ "notifications_none_for_topic_title": "לא קיבלת התראות בנושא הזה עדיין.", "notifications_none_for_topic_description": "כדי לשלוח התראות לנושא הזה, צריך לשלוח PUT או POST לכתובת הנושא הזה.", "notifications_none_for_any_title": "לא קיבלת התראות כלל.", - "notifications_no_subscriptions_title": "נראה שלא נרשמת למינויים עדיין." + "notifications_no_subscriptions_title": "נראה שלא נרשמת למינויים עדיין.", + "action_bar_toggle_mute": "השתקת/הפעלת התראות", + "action_bar_toggle_action_menu": "פתיחת/סגירת תפריט הפעולות", + "action_bar_profile_title": "פרופיל", + "action_bar_profile_settings": "הגדרות", + "action_bar_profile_logout": "יציאה", + "action_bar_sign_in": "כניסה", + "action_bar_sign_up": "הרשמה", + "message_bar_type_message": "כאן ניתן להקליד הודעה", + "message_bar_error_publishing": "שגיאה בפרסום ההתראה", + "message_bar_show_dialog": "הצגת חלונית פרסום", + "message_bar_publish": "פרסום הודעה", + "nav_topics_title": "נושאים שנרשמת אליהם", + "nav_button_all_notifications": "כל ההתראות", + "nav_button_account": "חשבון", + "nav_button_settings": "הגדרות", + "nav_button_documentation": "תיעוד", + "nav_button_publish_message": "פרסום התראה", + "nav_button_subscribe": "הרשמה לנושא", + "nav_button_muted": "התראות הושתקו", + "nav_button_connecting": "מתחבר", + "nav_upgrade_banner_label": "שדרוג ל־ntfy Pro" } From 046c0e8c793f1d00356254afa13732ce230af2ca Mon Sep 17 00:00:00 2001 From: Benjamin Kenawell Date: Wed, 18 Feb 2026 08:07:08 -0500 Subject: [PATCH 195/548] Add comment for zsh users in the terminal example Bash and Zsh terminals' history works slightly different. In bash, the current command is in the history already. In zsh, the history command does not have the currently running command in it. But it does set the HISTCMD variable to the entry number for the current command. So we can load the current command with `history "$HISTCMD"` in zsh and apply the same sed filter to get the same result as the bash `history | tail -n1` command. I think it makes more sense to leave this as a comment in the current example than to create a new example, since it otherwise works the exact same. I've added this to my workflow locally! --- docs/examples.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/examples.md b/docs/examples.md index 10bb014a..ee1de244 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -661,6 +661,8 @@ Add the following function and alias to your `.bashrc` or `.bash_profile`: local token=$(< ~/.ntfy_token) # Securely read the token local status_icon="$([ $exit_status -eq 0 ] && echo magic_wand || echo warning)" local last_command=$(history | tail -n1 | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//') + # for zsh users, use the same sed pattern but get the history differently. + # local last_command=$(history "$HISTCMD" | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//') curl -s -X POST "https://n.example.dev/alerts" \ -H "Authorization: Bearer $token" \ @@ -692,4 +694,4 @@ To test failure notifications: false; alert # Always fails (exit 1) ls --invalid; alert # Invalid option cat nonexistent_file; alert # File not found -``` \ No newline at end of file +``` From 2716ede6e1dc92f07719aff48c2a6fa1415009e2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 18 Feb 2026 20:22:44 -0500 Subject: [PATCH 196/548] Extract message cache into message/ package with model/ types Move message cache from server/message_cache.go into a dedicated message/ package with Store interface, SQLite and PostgreSQL implementations. Extract shared types into model/ package. --- message/store.go | 620 +++++++++++++++++ message/store_postgres.go | 118 ++++ message/store_postgres_schema.go | 90 +++ message/store_postgres_test.go | 120 ++++ message/store_sqlite.go | 138 ++++ message/store_sqlite_schema.go | 466 +++++++++++++ message/store_sqlite_test.go | 459 +++++++++++++ message/store_test.go | 767 +++++++++++++++++++++ model/model.go | 204 ++++++ server/actions.go | 17 +- server/log.go | 5 +- server/message_cache.go | 1104 ------------------------------ server/message_cache_test.go | 825 ---------------------- server/server.go | 78 ++- server/server_firebase.go | 9 +- server/server_firebase_dummy.go | 3 +- server/server_firebase_test.go | 13 +- server/server_test.go | 32 +- server/server_twilio.go | 3 +- server/server_webpush.go | 5 +- server/server_webpush_dummy.go | 4 +- server/smtp_sender.go | 9 +- server/smtp_sender_test.go | 16 +- server/smtp_server.go | 3 +- server/topic.go | 5 +- server/topic_test.go | 7 +- server/types.go | 218 +----- server/visitor.go | 5 +- user/manager_test.go | 1 - 29 files changed, 3142 insertions(+), 2202 deletions(-) create mode 100644 message/store.go create mode 100644 message/store_postgres.go create mode 100644 message/store_postgres_schema.go create mode 100644 message/store_postgres_test.go create mode 100644 message/store_sqlite.go create mode 100644 message/store_sqlite_schema.go create mode 100644 message/store_sqlite_test.go create mode 100644 message/store_test.go create mode 100644 model/model.go delete mode 100644 server/message_cache.go delete mode 100644 server/message_cache_test.go diff --git a/message/store.go b/message/store.go new file mode 100644 index 00000000..c51f732f --- /dev/null +++ b/message/store.go @@ -0,0 +1,620 @@ +package message + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/netip" + "strings" + "sync" + "time" + + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/util" +) + +const ( + tagMessageCache = "message_cache" +) + +var errNoRows = errors.New("no rows found") + +// Store is the interface for a message cache store +type Store interface { + AddMessage(m *model.Message) error + AddMessages(ms []*model.Message) error + DB() *sql.DB + Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) + MessagesDue() ([]*model.Message, error) + MessagesExpired() ([]string, error) + Message(id string) (*model.Message, error) + MarkPublished(m *model.Message) error + MessageCounts() (map[string]int, error) + Topics() ([]string, error) + DeleteMessages(ids ...string) error + DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) + ExpireMessages(topics ...string) error + AttachmentsExpired() ([]string, error) + MarkAttachmentsDeleted(ids ...string) error + AttachmentBytesUsedBySender(sender string) (int64, error) + AttachmentBytesUsedByUser(userID string) (int64, error) + UpdateStats(messages int64) error + Stats() (int64, error) + Close() error +} + +// storeQueries holds the database-specific SQL queries +type storeQueries struct { + insertMessage string + deleteMessage string + selectScheduledMessageIDsBySeqID string + deleteScheduledBySequenceID string + updateMessagesForTopicExpiry string + selectRowIDFromMessageID string + selectMessagesByID string + selectMessagesSinceTime string + selectMessagesSinceTimeScheduled string + selectMessagesSinceID string + selectMessagesSinceIDScheduled string + selectMessagesLatest string + selectMessagesDue string + selectMessagesExpired string + updateMessagePublished string + selectMessagesCount string + selectMessageCountPerTopic string + selectTopics string + updateAttachmentDeleted string + selectAttachmentsExpired string + selectAttachmentsSizeBySender string + selectAttachmentsSizeByUserID string + selectStats string + updateStats string +} + +// commonStore implements store operations that are identical across database backends +type commonStore struct { + db *sql.DB + queue *util.BatchingQueue[*model.Message] + nop bool + mu sync.Mutex + queries storeQueries +} + +func newCommonStore(db *sql.DB, queries storeQueries, batchSize int, batchTimeout time.Duration, nop bool) *commonStore { + var queue *util.BatchingQueue[*model.Message] + if batchSize > 0 || batchTimeout > 0 { + queue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout) + } + c := &commonStore{ + db: db, + queue: queue, + nop: nop, + queries: queries, + } + go c.processMessageBatches() + return c +} + +// DB returns the underlying database connection +func (c *commonStore) DB() *sql.DB { + return c.db +} + +// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asynchronously. +func (c *commonStore) AddMessage(m *model.Message) error { + if c.queue != nil { + c.queue.Enqueue(m) + return nil + } + return c.addMessages([]*model.Message{m}) +} + +// AddMessages synchronously stores a batch of messages to the message cache +func (c *commonStore) AddMessages(ms []*model.Message) error { + return c.addMessages(ms) +} + +func (c *commonStore) addMessages(ms []*model.Message) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.nop { + return nil + } + if len(ms) == 0 { + return nil + } + start := time.Now() + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + stmt, err := tx.Prepare(c.queries.insertMessage) + if err != nil { + return err + } + defer stmt.Close() + for _, m := range ms { + if m.Event != model.MessageEvent && m.Event != model.MessageDeleteEvent && m.Event != model.MessageClearEvent { + return model.ErrUnexpectedMessageType + } + published := m.Time <= time.Now().Unix() + tags := strings.Join(m.Tags, ",") + var attachmentName, attachmentType, attachmentURL string + var attachmentSize, attachmentExpires int64 + var attachmentDeleted bool + if m.Attachment != nil { + attachmentName = m.Attachment.Name + attachmentType = m.Attachment.Type + attachmentSize = m.Attachment.Size + attachmentExpires = m.Attachment.Expires + attachmentURL = m.Attachment.URL + } + var actionsStr string + if len(m.Actions) > 0 { + actionsBytes, err := json.Marshal(m.Actions) + if err != nil { + return err + } + actionsStr = string(actionsBytes) + } + var sender string + if m.Sender.IsValid() { + sender = m.Sender.String() + } + _, err := stmt.Exec( + m.ID, + m.SequenceID, + m.Time, + m.Event, + m.Expires, + m.Topic, + m.Message, + m.Title, + m.Priority, + tags, + m.Click, + m.Icon, + actionsStr, + attachmentName, + attachmentType, + attachmentSize, + attachmentExpires, + attachmentURL, + attachmentDeleted, // Always zero + sender, + m.User, + m.ContentType, + m.Encoding, + published, + ) + if err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + log.Tag(tagMessageCache).Err(err).Error("Writing %d message(s) failed (took %v)", len(ms), time.Since(start)) + return err + } + log.Tag(tagMessageCache).Debug("Wrote %d message(s) in %v", len(ms), time.Since(start)) + return nil +} + +func (c *commonStore) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { + if since.IsNone() { + return make([]*model.Message, 0), nil + } else if since.IsLatest() { + return c.messagesLatest(topic) + } else if since.IsID() { + return c.messagesSinceID(topic, since, scheduled) + } + return c.messagesSinceTime(topic, since, scheduled) +} + +func (c *commonStore) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { + var rows *sql.Rows + var err error + if scheduled { + rows, err = c.db.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix()) + } else { + rows, err = c.db.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix()) + } + if err != nil { + return nil, err + } + return readMessages(rows) +} + +func (c *commonStore) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { + idrows, err := c.db.Query(c.queries.selectRowIDFromMessageID, since.ID()) + if err != nil { + return nil, err + } + defer idrows.Close() + if !idrows.Next() { + return c.messagesSinceTime(topic, model.SinceAllMessages, scheduled) + } + var rowID int64 + if err := idrows.Scan(&rowID); err != nil { + return nil, err + } + idrows.Close() + var rows *sql.Rows + if scheduled { + rows, err = c.db.Query(c.queries.selectMessagesSinceIDScheduled, topic, rowID) + } else { + rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, rowID) + } + if err != nil { + return nil, err + } + return readMessages(rows) +} + +func (c *commonStore) messagesLatest(topic string) ([]*model.Message, error) { + rows, err := c.db.Query(c.queries.selectMessagesLatest, topic) + if err != nil { + return nil, err + } + return readMessages(rows) +} + +func (c *commonStore) MessagesDue() ([]*model.Message, error) { + rows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix()) + if err != nil { + return nil, err + } + return readMessages(rows) +} + +// MessagesExpired returns a list of IDs for messages that have expired (should be deleted) +func (c *commonStore) MessagesExpired() ([]string, error) { + rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + ids := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return ids, nil +} + +func (c *commonStore) Message(id string) (*model.Message, error) { + rows, err := c.db.Query(c.queries.selectMessagesByID, id) + if err != nil { + return nil, err + } + if !rows.Next() { + return nil, model.ErrMessageNotFound + } + defer rows.Close() + return readMessage(rows) +} + +func (c *commonStore) MarkPublished(m *model.Message) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.db.Exec(c.queries.updateMessagePublished, m.ID) + return err +} + +func (c *commonStore) MessageCounts() (map[string]int, error) { + rows, err := c.db.Query(c.queries.selectMessageCountPerTopic) + if err != nil { + return nil, err + } + defer rows.Close() + var topic string + var count int + counts := make(map[string]int) + for rows.Next() { + if err := rows.Scan(&topic, &count); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + counts[topic] = count + } + return counts, nil +} + +func (c *commonStore) Topics() ([]string, error) { + rows, err := c.db.Query(c.queries.selectTopics) + if err != nil { + return nil, err + } + defer rows.Close() + topics := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + topics = append(topics, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return topics, nil +} + +func (c *commonStore) DeleteMessages(ids ...string) error { + c.mu.Lock() + defer c.mu.Unlock() + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, id := range ids { + if _, err := tx.Exec(c.queries.deleteMessage, id); err != nil { + return err + } + } + return tx.Commit() +} + +// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID. +// It returns the message IDs of the deleted messages, which can be used to clean up attachment files. +func (c *commonStore) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + tx, err := c.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + rows, err := tx.Query(c.queries.selectScheduledMessageIDsBySeqID, topic, sequenceID) + if err != nil { + return nil, err + } + defer rows.Close() + ids := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + rows.Close() + if _, err := tx.Exec(c.queries.deleteScheduledBySequenceID, topic, sequenceID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return ids, nil +} + +func (c *commonStore) ExpireMessages(topics ...string) error { + c.mu.Lock() + defer c.mu.Unlock() + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, t := range topics { + if _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil { + return err + } + } + return tx.Commit() +} + +func (c *commonStore) AttachmentsExpired() ([]string, error) { + rows, err := c.db.Query(c.queries.selectAttachmentsExpired, time.Now().Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + ids := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return ids, nil +} + +func (c *commonStore) MarkAttachmentsDeleted(ids ...string) error { + c.mu.Lock() + defer c.mu.Unlock() + tx, err := c.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, id := range ids { + if _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil { + return err + } + } + return tx.Commit() +} + +func (c *commonStore) AttachmentBytesUsedBySender(sender string) (int64, error) { + rows, err := c.db.Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix()) + if err != nil { + return 0, err + } + return c.readAttachmentBytesUsed(rows) +} + +func (c *commonStore) AttachmentBytesUsedByUser(userID string) (int64, error) { + rows, err := c.db.Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix()) + if err != nil { + return 0, err + } + return c.readAttachmentBytesUsed(rows) +} + +func (c *commonStore) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) { + defer rows.Close() + var size int64 + if !rows.Next() { + return 0, errors.New("no rows found") + } + if err := rows.Scan(&size); err != nil { + return 0, err + } else if err := rows.Err(); err != nil { + return 0, err + } + return size, nil +} + +func (c *commonStore) UpdateStats(messages int64) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.db.Exec(c.queries.updateStats, messages) + return err +} + +func (c *commonStore) Stats() (messages int64, err error) { + rows, err := c.db.Query(c.queries.selectStats) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + if err := rows.Scan(&messages); err != nil { + return 0, err + } + return messages, nil +} + +func (c *commonStore) Close() error { + return c.db.Close() +} + +func (c *commonStore) processMessageBatches() { + if c.queue == nil { + return + } + for messages := range c.queue.Dequeue() { + if err := c.addMessages(messages); err != nil { + log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch") + } + } +} + +func readMessages(rows *sql.Rows) ([]*model.Message, error) { + defer rows.Close() + messages := make([]*model.Message, 0) + for rows.Next() { + m, err := readMessage(rows) + if err != nil { + return nil, err + } + messages = append(messages, m) + } + if err := rows.Err(); err != nil { + return nil, err + } + return messages, nil +} + +func readMessage(rows *sql.Rows) (*model.Message, error) { + var timestamp, expires, attachmentSize, attachmentExpires int64 + var priority int + var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string + err := rows.Scan( + &id, + &sequenceID, + ×tamp, + &event, + &expires, + &topic, + &msg, + &title, + &priority, + &tagsStr, + &click, + &icon, + &actionsStr, + &attachmentName, + &attachmentType, + &attachmentSize, + &attachmentExpires, + &attachmentURL, + &sender, + &user, + &contentType, + &encoding, + ) + if err != nil { + return nil, err + } + var tags []string + if tagsStr != "" { + tags = strings.Split(tagsStr, ",") + } + var actions []*model.Action + if actionsStr != "" { + if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil { + return nil, err + } + } + senderIP, err := netip.ParseAddr(sender) + if err != nil { + senderIP = netip.Addr{} // if no IP stored in database, return invalid address + } + var att *model.Attachment + if attachmentName != "" && attachmentURL != "" { + att = &model.Attachment{ + Name: attachmentName, + Type: attachmentType, + Size: attachmentSize, + Expires: attachmentExpires, + URL: attachmentURL, + } + } + return &model.Message{ + ID: id, + SequenceID: sequenceID, + Time: timestamp, + Expires: expires, + Event: event, + Topic: topic, + Message: msg, + Title: title, + Priority: priority, + Tags: tags, + Click: click, + Icon: icon, + Actions: actions, + Attachment: att, + Sender: senderIP, + User: user, + ContentType: contentType, + Encoding: encoding, + }, nil +} + +// Ensure commonStore implements Store +var _ Store = (*commonStore)(nil) + +// Needed by store.go but not part of Store interface; unused import guard +var _ = fmt.Sprintf diff --git a/message/store_postgres.go b/message/store_postgres.go new file mode 100644 index 00000000..3efab244 --- /dev/null +++ b/message/store_postgres.go @@ -0,0 +1,118 @@ +package message + +import ( + "database/sql" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver +) + +// PostgreSQL runtime query constants +const ( + pgInsertMessageQuery = ` + INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) + ` + pgDeleteMessageQuery = `DELETE FROM message WHERE mid = $1` + pgSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` + pgDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` + pgUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2` + pgSelectRowIDFromMessageID = `SELECT id FROM message WHERE mid = $1` + pgSelectMessagesByIDQuery = ` + 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 mid = $1 + ` + pgSelectMessagesSinceTimeQuery = ` + 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 topic = $1 AND time >= $2 AND published = TRUE + ORDER BY time, id + ` + pgSelectMessagesSinceTimeIncludeScheduledQuery = ` + 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 topic = $1 AND time >= $2 + ORDER BY time, id + ` + pgSelectMessagesSinceIDQuery = ` + 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 topic = $1 AND id > $2 AND published = TRUE + ORDER BY time, id + ` + pgSelectMessagesSinceIDIncludeScheduledQuery = ` + 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 topic = $1 AND (id > $2 OR published = FALSE) + ORDER BY time, id + ` + pgSelectMessagesLatestQuery = ` + 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 topic = $1 AND published = TRUE + ORDER BY time DESC, id DESC + LIMIT 1 + ` + pgSelectMessagesDueQuery = ` + 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 + ` + pgSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE` + pgUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1` + pgSelectMessagesCountQuery = `SELECT COUNT(*) FROM message` + pgSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM message GROUP BY topic` + pgSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic` + + pgUpdateAttachmentDeleted = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1` + pgSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE` + pgSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2` + pgSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2` + + pgSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` + pgUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` +) + +var pgQueries = storeQueries{ + insertMessage: pgInsertMessageQuery, + deleteMessage: pgDeleteMessageQuery, + selectScheduledMessageIDsBySeqID: pgSelectScheduledMessageIDsBySeqIDQuery, + deleteScheduledBySequenceID: pgDeleteScheduledBySequenceIDQuery, + updateMessagesForTopicExpiry: pgUpdateMessagesForTopicExpiryQuery, + selectRowIDFromMessageID: pgSelectRowIDFromMessageID, + selectMessagesByID: pgSelectMessagesByIDQuery, + selectMessagesSinceTime: pgSelectMessagesSinceTimeQuery, + selectMessagesSinceTimeScheduled: pgSelectMessagesSinceTimeIncludeScheduledQuery, + selectMessagesSinceID: pgSelectMessagesSinceIDQuery, + selectMessagesSinceIDScheduled: pgSelectMessagesSinceIDIncludeScheduledQuery, + selectMessagesLatest: pgSelectMessagesLatestQuery, + selectMessagesDue: pgSelectMessagesDueQuery, + selectMessagesExpired: pgSelectMessagesExpiredQuery, + updateMessagePublished: pgUpdateMessagePublishedQuery, + selectMessagesCount: pgSelectMessagesCountQuery, + selectMessageCountPerTopic: pgSelectMessageCountPerTopicQuery, + selectTopics: pgSelectTopicsQuery, + updateAttachmentDeleted: pgUpdateAttachmentDeleted, + selectAttachmentsExpired: pgSelectAttachmentsExpiredQuery, + selectAttachmentsSizeBySender: pgSelectAttachmentsSizeBySenderQuery, + selectAttachmentsSizeByUserID: pgSelectAttachmentsSizeByUserIDQuery, + selectStats: pgSelectStatsQuery, + updateStats: pgUpdateStatsQuery, +} + +// NewPostgresStore creates a new PostgreSQL-backed message cache store. +func NewPostgresStore(dsn string, batchSize int, batchTimeout time.Duration) (Store, error) { + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + return nil, err + } + if err := setupPostgresDB(db); err != nil { + return nil, err + } + return newCommonStore(db, pgQueries, batchSize, batchTimeout, false), nil +} diff --git a/message/store_postgres_schema.go b/message/store_postgres_schema.go new file mode 100644 index 00000000..e7aa3fe8 --- /dev/null +++ b/message/store_postgres_schema.go @@ -0,0 +1,90 @@ +package message + +import ( + "database/sql" + "fmt" +) + +// Initial PostgreSQL schema +const ( + pgCreateTablesQuery = ` + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_message_mid ON message (mid); + CREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id); + CREATE INDEX IF NOT EXISTS idx_message_time ON message (time); + CREATE INDEX IF NOT EXISTS idx_message_topic ON message (topic); + CREATE INDEX IF NOT EXISTS idx_message_expires ON message (expires); + CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender); + CREATE INDEX IF NOT EXISTS idx_message_user_id ON message (user_id); + CREATE INDEX IF NOT EXISTS idx_message_attachment_expires ON message (attachment_expires); + CREATE TABLE IF NOT EXISTS message_stats ( + key TEXT PRIMARY KEY, + value BIGINT + ); + INSERT INTO message_stats (key, value) VALUES ('messages', 0); + CREATE TABLE IF NOT EXISTS schema_version ( + store TEXT PRIMARY KEY, + version INT NOT NULL + ); + ` +) + +// PostgreSQL schema management queries +const ( + pgCurrentSchemaVersion = 14 + pgInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('message', $1)` + pgSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'` +) + +func setupPostgresDB(db *sql.DB) error { + var schemaVersion int + err := db.QueryRow(pgSelectSchemaVersionQuery).Scan(&schemaVersion) + if err != nil { + return setupNewPostgresDB(db) + } + if schemaVersion > pgCurrentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion) + } + return nil +} + +func setupNewPostgresDB(db *sql.DB) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(pgCreateTablesQuery); err != nil { + return err + } + if _, err := tx.Exec(pgInsertSchemaVersion, pgCurrentSchemaVersion); err != nil { + return err + } + return tx.Commit() +} diff --git a/message/store_postgres_test.go b/message/store_postgres_test.go new file mode 100644 index 00000000..930d700d --- /dev/null +++ b/message/store_postgres_test.go @@ -0,0 +1,120 @@ +package message_test + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/message" + "heckel.io/ntfy/v2/util" +) + +func newTestPostgresStore(t *testing.T) message.Store { + dsn := os.Getenv("NTFY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") + } + // Create a unique schema for this test + schema := fmt.Sprintf("test_%s", util.RandomString(10)) + setupDB, err := sql.Open("pgx", dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + // Open store with search_path set to the new schema + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + store, err := message.NewPostgresStore(u.String(), 0, 0) + require.Nil(t, err) + t.Cleanup(func() { + store.Close() + cleanDB, err := sql.Open("pgx", dsn) + if err == nil { + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + } + }) + return store +} + +func TestPostgresStore_Messages(t *testing.T) { + testCacheMessages(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MessagesLock(t *testing.T) { + testCacheMessagesLock(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MessagesScheduled(t *testing.T) { + testCacheMessagesScheduled(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_Topics(t *testing.T) { + testCacheTopics(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MessagesTagsPrioAndTitle(t *testing.T) { + testCacheMessagesTagsPrioAndTitle(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MessagesSinceID(t *testing.T) { + testCacheMessagesSinceID(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_Prune(t *testing.T) { + testCachePrune(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_Attachments(t *testing.T) { + testCacheAttachments(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_AttachmentsExpired(t *testing.T) { + testCacheAttachmentsExpired(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_Sender(t *testing.T) { + testSender(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_DeleteScheduledBySequenceID(t *testing.T) { + testDeleteScheduledBySequenceID(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MessageByID(t *testing.T) { + testMessageByID(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MarkPublished(t *testing.T) { + testMarkPublished(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_ExpireMessages(t *testing.T) { + testExpireMessages(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MarkAttachmentsDeleted(t *testing.T) { + testMarkAttachmentsDeleted(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_Stats(t *testing.T) { + testStats(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_AddMessages(t *testing.T) { + testAddMessages(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MessagesDue(t *testing.T) { + testMessagesDue(t, newTestPostgresStore(t)) +} + +func TestPostgresStore_MessageFieldRoundTrip(t *testing.T) { + testMessageFieldRoundTrip(t, newTestPostgresStore(t)) +} diff --git a/message/store_sqlite.go b/message/store_sqlite.go new file mode 100644 index 00000000..275fa43d --- /dev/null +++ b/message/store_sqlite.go @@ -0,0 +1,138 @@ +package message + +import ( + "database/sql" + "fmt" + "path/filepath" + "time" + + _ "github.com/mattn/go-sqlite3" // SQLite driver + "heckel.io/ntfy/v2/util" +) + +// SQLite runtime query constants +const ( + sqliteInsertMessageQuery = ` + INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + sqliteDeleteMessageQuery = `DELETE FROM messages WHERE mid = ?` + sqliteSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` + sqliteDeleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` + sqliteUpdateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` + sqliteSelectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` + sqliteSelectMessagesByIDQuery = ` + 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, content_type, encoding + FROM messages + WHERE mid = ? + ` + sqliteSelectMessagesSinceTimeQuery = ` + 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, content_type, encoding + FROM messages + WHERE topic = ? AND time >= ? AND published = 1 + ORDER BY time, id + ` + sqliteSelectMessagesSinceTimeIncludeScheduledQuery = ` + 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, content_type, encoding + FROM messages + WHERE topic = ? AND time >= ? + ORDER BY time, id + ` + sqliteSelectMessagesSinceIDQuery = ` + 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, content_type, encoding + FROM messages + WHERE topic = ? AND id > ? AND published = 1 + ORDER BY time, id + ` + sqliteSelectMessagesSinceIDIncludeScheduledQuery = ` + 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, content_type, encoding + FROM messages + WHERE topic = ? AND (id > ? OR published = 0) + ORDER BY time, id + ` + sqliteSelectMessagesLatestQuery = ` + 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, content_type, encoding + FROM messages + WHERE topic = ? AND published = 1 + ORDER BY time DESC, id DESC + LIMIT 1 + ` + sqliteSelectMessagesDueQuery = ` + 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, content_type, encoding + FROM messages + WHERE time <= ? AND published = 0 + ORDER BY time, id + ` + sqliteSelectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` + sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages` + sqliteSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` + sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` + + sqliteUpdateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` + sqliteSelectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` + sqliteSelectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?` + sqliteSelectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?` + + sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` + sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` +) + +var sqliteQueries = storeQueries{ + insertMessage: sqliteInsertMessageQuery, + deleteMessage: sqliteDeleteMessageQuery, + selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery, + deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery, + updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery, + selectRowIDFromMessageID: sqliteSelectRowIDFromMessageID, + selectMessagesByID: sqliteSelectMessagesByIDQuery, + selectMessagesSinceTime: sqliteSelectMessagesSinceTimeQuery, + selectMessagesSinceTimeScheduled: sqliteSelectMessagesSinceTimeIncludeScheduledQuery, + selectMessagesSinceID: sqliteSelectMessagesSinceIDQuery, + selectMessagesSinceIDScheduled: sqliteSelectMessagesSinceIDIncludeScheduledQuery, + selectMessagesLatest: sqliteSelectMessagesLatestQuery, + selectMessagesDue: sqliteSelectMessagesDueQuery, + selectMessagesExpired: sqliteSelectMessagesExpiredQuery, + updateMessagePublished: sqliteUpdateMessagePublishedQuery, + selectMessagesCount: sqliteSelectMessagesCountQuery, + selectMessageCountPerTopic: sqliteSelectMessageCountPerTopicQuery, + selectTopics: sqliteSelectTopicsQuery, + updateAttachmentDeleted: sqliteUpdateAttachmentDeleted, + selectAttachmentsExpired: sqliteSelectAttachmentsExpiredQuery, + selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery, + selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery, + selectStats: sqliteSelectStatsQuery, + updateStats: sqliteUpdateStatsQuery, +} + +// NewSQLiteStore creates a SQLite file-backed cache +func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (Store, error) { + parentDir := filepath.Dir(filename) + if !util.FileExists(parentDir) { + return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir) + } + db, err := sql.Open("sqlite3", filename) + if err != nil { + return nil, err + } + if err := setupSQLite(db, startupQueries, cacheDuration); err != nil { + return nil, err + } + return newCommonStore(db, sqliteQueries, batchSize, batchTimeout, nop), nil +} + +// NewMemStore creates an in-memory cache +func NewMemStore() (Store, error) { + return NewSQLiteStore(createMemoryFilename(), "", 0, 0, 0, false) +} + +// NewNopStore creates an in-memory cache that discards all messages; +// it is always empty and can be used if caching is entirely disabled +func NewNopStore() (Store, error) { + return NewSQLiteStore(createMemoryFilename(), "", 0, 0, 0, true) +} + +// createMemoryFilename creates a unique memory filename to use for the SQLite backend. +func createMemoryFilename() string { + return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10)) +} diff --git a/message/store_sqlite_schema.go b/message/store_sqlite_schema.go new file mode 100644 index 00000000..cd01555f --- /dev/null +++ b/message/store_sqlite_schema.go @@ -0,0 +1,466 @@ +package message + +import ( + "database/sql" + "fmt" + "time" + + "heckel.io/ntfy/v2/log" +) + +// Initial SQLite schema +const ( + sqliteCreateMessagesTableQuery = ` + BEGIN; + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mid TEXT NOT NULL, + sequence_id TEXT NOT NULL, + time INT NOT NULL, + event TEXT NOT NULL, + expires INT 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 INT NOT NULL, + attachment_expires INT NOT NULL, + attachment_url TEXT NOT NULL, + attachment_deleted INT NOT NULL, + sender TEXT NOT NULL, + user TEXT NOT NULL, + content_type TEXT NOT NULL, + encoding TEXT NOT NULL, + published INT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); + CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); + CREATE INDEX IF NOT EXISTS idx_time ON messages (time); + CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); + CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); + CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); + CREATE INDEX IF NOT EXISTS idx_user ON messages (user); + CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); + CREATE TABLE IF NOT EXISTS stats ( + key TEXT PRIMARY KEY, + value INT + ); + INSERT INTO stats (key, value) VALUES ('messages', 0); + COMMIT; + ` +) + +// Schema version management for SQLite +const ( + sqliteCurrentSchemaVersion = 14 + sqliteCreateSchemaVersionTableQuery = ` + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + ` + sqliteInsertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + sqliteUpdateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` + sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` +) + +// Schema migrations for SQLite +const ( + // 0 -> 1 + sqliteMigrate0To1AlterMessagesTableQuery = ` + BEGIN; + ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0); + ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT(''); + COMMIT; + ` + + // 1 -> 2 + sqliteMigrate1To2AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1); + ` + + // 2 -> 3 + sqliteMigrate2To3AlterMessagesTableQuery = ` + BEGIN; + ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT(''); + COMMIT; + ` + // 3 -> 4 + sqliteMigrate3To4AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT(''); + ` + + // 4 -> 5 + sqliteMigrate4To5AlterMessagesTableQuery = ` + BEGIN; + CREATE TABLE IF NOT EXISTS messages_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mid TEXT NOT NULL, + time INT 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, + attachment_name TEXT NOT NULL, + attachment_type TEXT NOT NULL, + attachment_size INT NOT NULL, + attachment_expires INT NOT NULL, + attachment_url TEXT NOT NULL, + attachment_owner TEXT NOT NULL, + encoding TEXT NOT NULL, + published INT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid); + CREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic); + INSERT + INTO messages_new ( + mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, + attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published) + SELECT + id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, + attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published + FROM messages; + DROP TABLE messages; + ALTER TABLE messages_new RENAME TO messages; + COMMIT; + ` + + // 5 -> 6 + sqliteMigrate5To6AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT(''); + ` + + // 6 -> 7 + sqliteMigrate6To7AlterMessagesTableQuery = ` + ALTER TABLE messages RENAME COLUMN attachment_owner TO sender; + ` + + // 7 -> 8 + sqliteMigrate7To8AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT(''); + ` + + // 8 -> 9 + sqliteMigrate8To9AlterMessagesTableQuery = ` + CREATE INDEX IF NOT EXISTS idx_time ON messages (time); + ` + + // 9 -> 10 + sqliteMigrate9To10AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0'); + CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); + CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); + CREATE INDEX IF NOT EXISTS idx_user ON messages (user); + CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); + ` + sqliteMigrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?` + + // 10 -> 11 + sqliteMigrate10To11AlterMessagesTableQuery = ` + CREATE TABLE IF NOT EXISTS stats ( + key TEXT PRIMARY KEY, + value INT + ); + INSERT INTO stats (key, value) VALUES ('messages', 0); + ` + + // 11 -> 12 + sqliteMigrate11To12AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT(''); + ` + + // 12 -> 13 + sqliteMigrate12To13AlterMessagesTableQuery = ` + CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); + ` + + // 13 -> 14 + sqliteMigrate13To14AlterMessagesTableQuery = ` + ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message'); + CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); + ` +) + +var ( + sqliteMigrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{ + 0: sqliteMigrateFrom0, + 1: sqliteMigrateFrom1, + 2: sqliteMigrateFrom2, + 3: sqliteMigrateFrom3, + 4: sqliteMigrateFrom4, + 5: sqliteMigrateFrom5, + 6: sqliteMigrateFrom6, + 7: sqliteMigrateFrom7, + 8: sqliteMigrateFrom8, + 9: sqliteMigrateFrom9, + 10: sqliteMigrateFrom10, + 11: sqliteMigrateFrom11, + 12: sqliteMigrateFrom12, + 13: sqliteMigrateFrom13, + } +) + +func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) error { + if err := runSQLiteStartupQueries(db, startupQueries); err != nil { + return err + } + // If 'messages' table does not exist, this must be a new database + rowsMC, err := db.Query(sqliteSelectMessagesCountQuery) + if err != nil { + return setupNewSQLite(db) + } + rowsMC.Close() + // If 'messages' table exists, check 'schemaVersion' table + schemaVersion := 0 + rowsSV, err := db.Query(sqliteSelectSchemaVersionQuery) + if err == nil { + defer rowsSV.Close() + if !rowsSV.Next() { + return fmt.Errorf("cannot determine schema version: cache file may be corrupt") + } + if err := rowsSV.Scan(&schemaVersion); err != nil { + return err + } + rowsSV.Close() + } + // Do migrations + 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, cacheDuration); err != nil { + return err + } + } + return nil +} + +func setupNewSQLite(db *sql.DB) error { + if _, err := db.Exec(sqliteCreateMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteInsertSchemaVersion, sqliteCurrentSchemaVersion); err != nil { + return err + } + return nil +} + +func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { + if startupQueries != "" { + if _, err := db.Exec(startupQueries); err != nil { + return err + } + } + return nil +} + +func sqliteMigrateFrom0(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1") + if _, err := db.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteInsertSchemaVersion, 1); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom1(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2") + if _, err := db.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteUpdateSchemaVersion, 2); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom2(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3") + if _, err := db.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteUpdateSchemaVersion, 3); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom3(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4") + if _, err := db.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteUpdateSchemaVersion, 4); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom4(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5") + if _, err := db.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteUpdateSchemaVersion, 5); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom5(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6") + if _, err := db.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteUpdateSchemaVersion, 6); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom6(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7") + if _, err := db.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteUpdateSchemaVersion, 7); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom7(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8") + if _, err := db.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteUpdateSchemaVersion, 8); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom8(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9") + if _, err := db.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(sqliteUpdateSchemaVersion, 9); err != nil { + return err + } + return nil +} + +func sqliteMigrateFrom9(db *sql.DB, cacheDuration time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 10); err != nil { + return err + } + return tx.Commit() +} + +func sqliteMigrateFrom10(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 11); err != nil { + return err + } + return tx.Commit() +} + +func sqliteMigrateFrom11(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 12); err != nil { + return err + } + return tx.Commit() +} + +func sqliteMigrateFrom12(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 13); err != nil { + return err + } + return tx.Commit() +} + +func sqliteMigrateFrom13(db *sql.DB, _ time.Duration) error { + log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersion, 14); err != nil { + return err + } + return tx.Commit() +} diff --git a/message/store_sqlite_test.go b/message/store_sqlite_test.go new file mode 100644 index 00000000..aa102044 --- /dev/null +++ b/message/store_sqlite_test.go @@ -0,0 +1,459 @@ +package message_test + +import ( + "database/sql" + "fmt" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" // SQLite driver + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/message" + "heckel.io/ntfy/v2/model" +) + +func TestSqliteStore_Messages(t *testing.T) { + testCacheMessages(t, newSqliteTestStore(t)) +} + +func TestMemStore_Messages(t *testing.T) { + testCacheMessages(t, newMemTestStore(t)) +} + +func TestSqliteStore_MessagesLock(t *testing.T) { + testCacheMessagesLock(t, newSqliteTestStore(t)) +} + +func TestMemStore_MessagesLock(t *testing.T) { + testCacheMessagesLock(t, newMemTestStore(t)) +} + +func TestSqliteStore_MessagesScheduled(t *testing.T) { + testCacheMessagesScheduled(t, newSqliteTestStore(t)) +} + +func TestMemStore_MessagesScheduled(t *testing.T) { + testCacheMessagesScheduled(t, newMemTestStore(t)) +} + +func TestSqliteStore_Topics(t *testing.T) { + testCacheTopics(t, newSqliteTestStore(t)) +} + +func TestMemStore_Topics(t *testing.T) { + testCacheTopics(t, newMemTestStore(t)) +} + +func TestSqliteStore_MessagesTagsPrioAndTitle(t *testing.T) { + testCacheMessagesTagsPrioAndTitle(t, newSqliteTestStore(t)) +} + +func TestMemStore_MessagesTagsPrioAndTitle(t *testing.T) { + testCacheMessagesTagsPrioAndTitle(t, newMemTestStore(t)) +} + +func TestSqliteStore_MessagesSinceID(t *testing.T) { + testCacheMessagesSinceID(t, newSqliteTestStore(t)) +} + +func TestMemStore_MessagesSinceID(t *testing.T) { + testCacheMessagesSinceID(t, newMemTestStore(t)) +} + +func TestSqliteStore_Prune(t *testing.T) { + testCachePrune(t, newSqliteTestStore(t)) +} + +func TestMemStore_Prune(t *testing.T) { + testCachePrune(t, newMemTestStore(t)) +} + +func TestSqliteStore_Attachments(t *testing.T) { + testCacheAttachments(t, newSqliteTestStore(t)) +} + +func TestMemStore_Attachments(t *testing.T) { + testCacheAttachments(t, newMemTestStore(t)) +} + +func TestSqliteStore_AttachmentsExpired(t *testing.T) { + testCacheAttachmentsExpired(t, newSqliteTestStore(t)) +} + +func TestMemStore_AttachmentsExpired(t *testing.T) { + testCacheAttachmentsExpired(t, newMemTestStore(t)) +} + +func TestSqliteStore_Sender(t *testing.T) { + testSender(t, newSqliteTestStore(t)) +} + +func TestMemStore_Sender(t *testing.T) { + testSender(t, newMemTestStore(t)) +} + +func TestSqliteStore_DeleteScheduledBySequenceID(t *testing.T) { + testDeleteScheduledBySequenceID(t, newSqliteTestStore(t)) +} + +func TestMemStore_DeleteScheduledBySequenceID(t *testing.T) { + testDeleteScheduledBySequenceID(t, newMemTestStore(t)) +} + +func TestSqliteStore_MessageByID(t *testing.T) { + testMessageByID(t, newSqliteTestStore(t)) +} + +func TestMemStore_MessageByID(t *testing.T) { + testMessageByID(t, newMemTestStore(t)) +} + +func TestSqliteStore_MarkPublished(t *testing.T) { + testMarkPublished(t, newSqliteTestStore(t)) +} + +func TestMemStore_MarkPublished(t *testing.T) { + testMarkPublished(t, newMemTestStore(t)) +} + +func TestSqliteStore_ExpireMessages(t *testing.T) { + testExpireMessages(t, newSqliteTestStore(t)) +} + +func TestMemStore_ExpireMessages(t *testing.T) { + testExpireMessages(t, newMemTestStore(t)) +} + +func TestSqliteStore_MarkAttachmentsDeleted(t *testing.T) { + testMarkAttachmentsDeleted(t, newSqliteTestStore(t)) +} + +func TestMemStore_MarkAttachmentsDeleted(t *testing.T) { + testMarkAttachmentsDeleted(t, newMemTestStore(t)) +} + +func TestSqliteStore_Stats(t *testing.T) { + testStats(t, newSqliteTestStore(t)) +} + +func TestMemStore_Stats(t *testing.T) { + testStats(t, newMemTestStore(t)) +} + +func TestSqliteStore_AddMessages(t *testing.T) { + testAddMessages(t, newSqliteTestStore(t)) +} + +func TestMemStore_AddMessages(t *testing.T) { + testAddMessages(t, newMemTestStore(t)) +} + +func TestSqliteStore_MessagesDue(t *testing.T) { + testMessagesDue(t, newSqliteTestStore(t)) +} + +func TestMemStore_MessagesDue(t *testing.T) { + testMessagesDue(t, newMemTestStore(t)) +} + +func TestSqliteStore_MessageFieldRoundTrip(t *testing.T) { + testMessageFieldRoundTrip(t, newSqliteTestStore(t)) +} + +func TestMemStore_MessageFieldRoundTrip(t *testing.T) { + testMessageFieldRoundTrip(t, newMemTestStore(t)) +} + +func TestSqliteStore_Migration_From0(t *testing.T) { + filename := newSqliteTestStoreFile(t) + db, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + + // Create "version 0" schema + _, err = db.Exec(` + BEGIN; + CREATE TABLE IF NOT EXISTS messages ( + id VARCHAR(20) PRIMARY KEY, + time INT NOT NULL, + topic VARCHAR(64) NOT NULL, + message VARCHAR(1024) NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); + COMMIT; + `) + require.Nil(t, err) + + // Insert a bunch of messages + for i := 0; i < 10; i++ { + _, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`, + fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i)) + require.Nil(t, err) + } + require.Nil(t, db.Close()) + + // Create store to trigger migration + s := newSqliteTestStoreFromFile(t, filename, "") + checkSqliteSchemaVersion(t, filename) + + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 10, len(messages)) + require.Equal(t, "some message 5", messages[5].Message) + require.Equal(t, "", messages[5].Title) + require.Nil(t, messages[5].Tags) + require.Equal(t, 0, messages[5].Priority) +} + +func TestSqliteStore_Migration_From1(t *testing.T) { + filename := newSqliteTestStoreFile(t) + db, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + + // Create "version 1" schema + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS messages ( + id VARCHAR(20) PRIMARY KEY, + time INT NOT NULL, + topic VARCHAR(64) NOT NULL, + message VARCHAR(512) NOT NULL, + title VARCHAR(256) NOT NULL, + priority INT NOT NULL, + tags VARCHAR(256) NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO schemaVersion (id, version) VALUES (1, 1); + `) + require.Nil(t, err) + + // Insert a bunch of messages + for i := 0; i < 10; i++ { + _, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`, + fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "") + require.Nil(t, err) + } + require.Nil(t, db.Close()) + + // Create store to trigger migration + s := newSqliteTestStoreFromFile(t, filename, "") + checkSqliteSchemaVersion(t, filename) + + // Add delayed message + delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message") + delayedMessage.Time = time.Now().Add(time.Minute).Unix() + require.Nil(t, s.AddMessage(delayedMessage)) + + // 10, not 11! + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 10, len(messages)) + + // 11! + messages, err = s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 11, len(messages)) + + // Check that index "idx_topic" exists + verifyDB, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + defer verifyDB.Close() + rows, err := verifyDB.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`) + require.Nil(t, err) + require.True(t, rows.Next()) + var indexName string + require.Nil(t, rows.Scan(&indexName)) + require.Equal(t, "idx_topic", indexName) + require.Nil(t, rows.Close()) +} + +func TestSqliteStore_Migration_From9(t *testing.T) { + // This primarily tests the awkward migration that introduces the "expires" column. + // The migration logic has to update the column, using the existing "cache-duration" value. + + filename := newSqliteTestStoreFile(t) + db, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + + // Create "version 9" schema + _, err = db.Exec(` + BEGIN; + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mid TEXT NOT NULL, + time INT 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 INT NOT NULL, + attachment_expires INT NOT NULL, + attachment_url TEXT NOT NULL, + sender TEXT NOT NULL, + encoding TEXT NOT NULL, + published INT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); + CREATE INDEX IF NOT EXISTS idx_time ON messages (time); + CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); + CREATE TABLE IF NOT EXISTS schemaVersion ( + id INT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO schemaVersion (id, version) VALUES (1, 9); + COMMIT; + `) + require.Nil(t, err) + + // Insert a bunch of messages + insertQuery := ` + INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + for i := 0; i < 10; i++ { + _, err = db.Exec( + insertQuery, + fmt.Sprintf("abcd%d", i), + time.Now().Unix(), + "mytopic", + fmt.Sprintf("some message %d", i), + "", // title + 0, // priority + "", // tags + "", // click + "", // icon + "", // actions + "", // attachment_name + "", // attachment_type + 0, // attachment_size + 0, // attachment_expires + "", // attachment_url + "9.9.9.9", // sender + "", // encoding + 1, // published + ) + require.Nil(t, err) + } + require.Nil(t, db.Close()) + + // Create store to trigger migration + cacheDuration := 17 * time.Hour + s, err := message.NewSQLiteStore(filename, "", cacheDuration, 0, 0, false) + require.Nil(t, err) + t.Cleanup(func() { s.Close() }) + checkSqliteSchemaVersion(t, filename) + + // Check version + verifyDB, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + defer verifyDB.Close() + rows, err := verifyDB.Query(`SELECT version FROM schemaVersion WHERE id = 1`) + require.Nil(t, err) + require.True(t, rows.Next()) + var version int + require.Nil(t, rows.Scan(&version)) + require.Equal(t, 14, version) + require.Nil(t, rows.Close()) + + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 10, len(messages)) + for _, m := range messages { + require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix()) + require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix()) + } +} + +func TestSqliteStore_StartupQueries_WAL(t *testing.T) { + filename := newSqliteTestStoreFile(t) + startupQueries := `pragma journal_mode = WAL; +pragma synchronous = normal; +pragma temp_store = memory;` + s, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false) + require.Nil(t, err) + t.Cleanup(func() { s.Close() }) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "some message"))) + require.FileExists(t, filename) + require.FileExists(t, filename+"-wal") + require.FileExists(t, filename+"-shm") +} + +func TestSqliteStore_StartupQueries_None(t *testing.T) { + filename := newSqliteTestStoreFile(t) + s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false) + require.Nil(t, err) + t.Cleanup(func() { s.Close() }) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "some message"))) + require.FileExists(t, filename) + require.NoFileExists(t, filename+"-wal") + require.NoFileExists(t, filename+"-shm") +} + +func TestSqliteStore_StartupQueries_Fail(t *testing.T) { + filename := newSqliteTestStoreFile(t) + _, err := message.NewSQLiteStore(filename, `xx error`, time.Hour, 0, 0, false) + require.Error(t, err) +} + +func TestNopStore(t *testing.T) { + s, err := message.NewNopStore() + require.Nil(t, err) + t.Cleanup(func() { s.Close() }) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "my message"))) + + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Empty(t, messages) + + topics, err := s.Topics() + require.Nil(t, err) + require.Empty(t, topics) +} + +func newSqliteTestStore(t *testing.T) message.Store { + filename := filepath.Join(t.TempDir(), "cache.db") + s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false) + require.Nil(t, err) + t.Cleanup(func() { s.Close() }) + return s +} + +func newSqliteTestStoreFile(t *testing.T) string { + return filepath.Join(t.TempDir(), "cache.db") +} + +func newSqliteTestStoreFromFile(t *testing.T, filename, startupQueries string) message.Store { + s, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false) + require.Nil(t, err) + t.Cleanup(func() { s.Close() }) + return s +} + +func newMemTestStore(t *testing.T) message.Store { + s, err := message.NewMemStore() + require.Nil(t, err) + t.Cleanup(func() { s.Close() }) + return s +} + +func checkSqliteSchemaVersion(t *testing.T, filename string) { + db, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + defer db.Close() + rows, err := db.Query(`SELECT version FROM schemaVersion`) + require.Nil(t, err) + require.True(t, rows.Next()) + var schemaVersion int + require.Nil(t, rows.Scan(&schemaVersion)) + require.Equal(t, 14, schemaVersion) + require.Nil(t, rows.Close()) +} diff --git a/message/store_test.go b/message/store_test.go new file mode 100644 index 00000000..e3297c96 --- /dev/null +++ b/message/store_test.go @@ -0,0 +1,767 @@ +package message_test + +import ( + "net/netip" + "sort" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/message" + "heckel.io/ntfy/v2/model" +) + +func testCacheMessages(t *testing.T, s message.Store) { + m1 := model.NewDefaultMessage("mytopic", "my message") + m1.Time = 1 + + m2 := model.NewDefaultMessage("mytopic", "my other message") + m2.Time = 2 + + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("example", "my example message"))) + require.Nil(t, s.AddMessage(m2)) + + // Adding invalid + require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewKeepaliveMessage("mytopic"))) // These should not be added! + require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewOpenMessage("example"))) // These should not be added! + + // mytopic: count + counts, err := s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 2, counts["mytopic"]) + + // mytopic: since all + messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) + require.Equal(t, 2, len(messages)) + require.Equal(t, "my message", messages[0].Message) + require.Equal(t, "mytopic", messages[0].Topic) + require.Equal(t, model.MessageEvent, messages[0].Event) + require.Equal(t, "", messages[0].Title) + require.Equal(t, 0, messages[0].Priority) + require.Nil(t, messages[0].Tags) + require.Equal(t, "my other message", messages[1].Message) + + // mytopic: since none + messages, _ = s.Messages("mytopic", model.SinceNoMessages, false) + require.Empty(t, messages) + + // mytopic: since m1 (by ID) + messages, _ = s.Messages("mytopic", model.NewSinceID(m1.ID), false) + require.Equal(t, 1, len(messages)) + require.Equal(t, m2.ID, messages[0].ID) + require.Equal(t, "my other message", messages[0].Message) + require.Equal(t, "mytopic", messages[0].Topic) + + // mytopic: since 2 + messages, _ = s.Messages("mytopic", model.NewSinceTime(2), false) + require.Equal(t, 1, len(messages)) + require.Equal(t, "my other message", messages[0].Message) + + // mytopic: latest + messages, _ = s.Messages("mytopic", model.SinceLatestMessage, false) + require.Equal(t, 1, len(messages)) + require.Equal(t, "my other message", messages[0].Message) + + // example: count + counts, err = s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 1, counts["example"]) + + // example: since all + messages, _ = s.Messages("example", model.SinceAllMessages, false) + require.Equal(t, "my example message", messages[0].Message) + + // non-existing: count + counts, err = s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 0, counts["doesnotexist"]) + + // non-existing: since all + messages, _ = s.Messages("doesnotexist", model.SinceAllMessages, false) + require.Empty(t, messages) +} + +func testCacheMessagesLock(t *testing.T, s message.Store) { + var wg sync.WaitGroup + for i := 0; i < 5000; i++ { + wg.Add(1) + go func() { + assert.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "test message"))) + wg.Done() + }() + } + wg.Wait() +} + +func testCacheMessagesScheduled(t *testing.T, s message.Store) { + m1 := model.NewDefaultMessage("mytopic", "message 1") + m2 := model.NewDefaultMessage("mytopic", "message 2") + m2.Time = time.Now().Add(time.Hour).Unix() + m3 := model.NewDefaultMessage("mytopic", "message 3") + m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! + m4 := model.NewDefaultMessage("mytopic2", "message 4") + m4.Time = time.Now().Add(time.Minute).Unix() + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m3)) + + messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) // exclude scheduled + require.Equal(t, 1, len(messages)) + require.Equal(t, "message 1", messages[0].Message) + + messages, _ = s.Messages("mytopic", model.SinceAllMessages, true) // include scheduled + require.Equal(t, 3, len(messages)) + require.Equal(t, "message 1", messages[0].Message) + require.Equal(t, "message 3", messages[1].Message) // Order! + require.Equal(t, "message 2", messages[2].Message) + + messages, _ = s.MessagesDue() + require.Empty(t, messages) +} + +func testCacheTopics(t *testing.T, s message.Store) { + require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic1", "my example message"))) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 1"))) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 2"))) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 3"))) + + topics, err := s.Topics() + if err != nil { + t.Fatal(err) + } + require.Equal(t, 2, len(topics)) + require.Contains(t, topics, "topic1") + require.Contains(t, topics, "topic2") +} + +func testCacheMessagesTagsPrioAndTitle(t *testing.T, s message.Store) { + m := model.NewDefaultMessage("mytopic", "some message") + m.Tags = []string{"tag1", "tag2"} + m.Priority = 5 + m.Title = "some title" + require.Nil(t, s.AddMessage(m)) + + messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) + require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags) + require.Equal(t, 5, messages[0].Priority) + require.Equal(t, "some title", messages[0].Title) +} + +func testCacheMessagesSinceID(t *testing.T, s message.Store) { + m1 := model.NewDefaultMessage("mytopic", "message 1") + m1.Time = 100 + m2 := model.NewDefaultMessage("mytopic", "message 2") + m2.Time = 200 + m3 := model.NewDefaultMessage("mytopic", "message 3") + m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 + m4 := model.NewDefaultMessage("mytopic", "message 4") + m4.Time = 400 + m5 := model.NewDefaultMessage("mytopic", "message 5") + m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 + m6 := model.NewDefaultMessage("mytopic", "message 6") + m6.Time = 600 + m7 := model.NewDefaultMessage("mytopic", "message 7") + m7.Time = 700 + + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m3)) + require.Nil(t, s.AddMessage(m4)) + require.Nil(t, s.AddMessage(m5)) + require.Nil(t, s.AddMessage(m6)) + require.Nil(t, s.AddMessage(m7)) + + // Case 1: Since ID exists, exclude scheduled + messages, _ := s.Messages("mytopic", model.NewSinceID(m2.ID), false) + require.Equal(t, 3, len(messages)) + require.Equal(t, "message 4", messages[0].Message) + require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5! + require.Equal(t, "message 7", messages[2].Message) + + // Case 2: Since ID exists, include scheduled + messages, _ = s.Messages("mytopic", model.NewSinceID(m2.ID), true) + require.Equal(t, 5, len(messages)) + require.Equal(t, "message 4", messages[0].Message) + require.Equal(t, "message 6", messages[1].Message) + require.Equal(t, "message 7", messages[2].Message) + require.Equal(t, "message 5", messages[3].Message) // Order! + require.Equal(t, "message 3", messages[4].Message) // Order! + + // Case 3: Since ID does not exist (-> Return all messages), include scheduled + messages, _ = s.Messages("mytopic", model.NewSinceID("doesntexist"), true) + require.Equal(t, 7, len(messages)) + require.Equal(t, "message 1", messages[0].Message) + require.Equal(t, "message 2", messages[1].Message) + require.Equal(t, "message 4", messages[2].Message) + require.Equal(t, "message 6", messages[3].Message) + require.Equal(t, "message 7", messages[4].Message) + require.Equal(t, "message 5", messages[5].Message) // Order! + require.Equal(t, "message 3", messages[6].Message) // Order! + + // Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled + messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), false) + require.Equal(t, 0, len(messages)) + + // Case 5: Since ID exists and is last message (-> Return no messages), include scheduled + messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), true) + require.Equal(t, 2, len(messages)) + require.Equal(t, "message 5", messages[0].Message) + require.Equal(t, "message 3", messages[1].Message) +} + +func testCachePrune(t *testing.T, s message.Store) { + now := time.Now().Unix() + + m1 := model.NewDefaultMessage("mytopic", "my message") + m1.Time = now - 10 + m1.Expires = now - 5 + + m2 := model.NewDefaultMessage("mytopic", "my other message") + m2.Time = now - 5 + m2.Expires = now + 5 // In the future + + m3 := model.NewDefaultMessage("another_topic", "and another one") + m3.Time = now - 12 + m3.Expires = now - 2 + + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m3)) + + counts, err := s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 2, counts["mytopic"]) + require.Equal(t, 1, counts["another_topic"]) + + expiredMessageIDs, err := s.MessagesExpired() + require.Nil(t, err) + require.Nil(t, s.DeleteMessages(expiredMessageIDs...)) + + counts, err = s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 1, counts["mytopic"]) + require.Equal(t, 0, counts["another_topic"]) + + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "my other message", messages[0].Message) +} + +func testCacheAttachments(t *testing.T, s message.Store) { + expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired + m := model.NewDefaultMessage("mytopic", "flower for you") + m.ID = "m1" + m.SequenceID = "m1" + m.Sender = netip.MustParseAddr("1.2.3.4") + m.Attachment = &model.Attachment{ + Name: "flower.jpg", + Type: "image/jpeg", + Size: 5000, + Expires: expires1, + URL: "https://ntfy.sh/file/AbDeFgJhal.jpg", + } + require.Nil(t, s.AddMessage(m)) + + expires2 := time.Now().Add(2 * time.Hour).Unix() // Future + m = model.NewDefaultMessage("mytopic", "sending you a car") + m.ID = "m2" + m.SequenceID = "m2" + m.Sender = netip.MustParseAddr("1.2.3.4") + m.Attachment = &model.Attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Size: 10000, + Expires: expires2, + URL: "https://ntfy.sh/file/aCaRURL.jpg", + } + require.Nil(t, s.AddMessage(m)) + + expires3 := time.Now().Add(1 * time.Hour).Unix() // Future + m = model.NewDefaultMessage("another-topic", "sending you another car") + m.ID = "m3" + m.SequenceID = "m3" + m.User = "u_BAsbaAa" + m.Sender = netip.MustParseAddr("5.6.7.8") + m.Attachment = &model.Attachment{ + Name: "another-car.jpg", + Type: "image/jpeg", + Size: 20000, + Expires: expires3, + URL: "https://ntfy.sh/file/zakaDHFW.jpg", + } + require.Nil(t, s.AddMessage(m)) + + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + + require.Equal(t, "flower for you", messages[0].Message) + require.Equal(t, "flower.jpg", messages[0].Attachment.Name) + require.Equal(t, "image/jpeg", messages[0].Attachment.Type) + require.Equal(t, int64(5000), messages[0].Attachment.Size) + require.Equal(t, expires1, messages[0].Attachment.Expires) + require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) + require.Equal(t, "1.2.3.4", messages[0].Sender.String()) + + require.Equal(t, "sending you a car", messages[1].Message) + require.Equal(t, "car.jpg", messages[1].Attachment.Name) + require.Equal(t, "image/jpeg", messages[1].Attachment.Type) + require.Equal(t, int64(10000), messages[1].Attachment.Size) + require.Equal(t, expires2, messages[1].Attachment.Expires) + require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) + require.Equal(t, "1.2.3.4", messages[1].Sender.String()) + + size, err := s.AttachmentBytesUsedBySender("1.2.3.4") + require.Nil(t, err) + require.Equal(t, int64(10000), size) + + size, err = s.AttachmentBytesUsedBySender("5.6.7.8") + require.Nil(t, err) + require.Equal(t, int64(0), size) // Accounted to the user, not the IP! + + size, err = s.AttachmentBytesUsedByUser("u_BAsbaAa") + require.Nil(t, err) + require.Equal(t, int64(20000), size) +} + +func testCacheAttachmentsExpired(t *testing.T, s message.Store) { + m := model.NewDefaultMessage("mytopic", "flower for you") + m.ID = "m1" + m.SequenceID = "m1" + m.Expires = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m)) + + m = model.NewDefaultMessage("mytopic", "message with attachment") + m.ID = "m2" + m.SequenceID = "m2" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &model.Attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Size: 10000, + Expires: time.Now().Add(2 * time.Hour).Unix(), + URL: "https://ntfy.sh/file/aCaRURL.jpg", + } + require.Nil(t, s.AddMessage(m)) + + m = model.NewDefaultMessage("mytopic", "message with external attachment") + m.ID = "m3" + m.SequenceID = "m3" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &model.Attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Expires: 0, // Unknown! + URL: "https://somedomain.com/car.jpg", + } + require.Nil(t, s.AddMessage(m)) + + m = model.NewDefaultMessage("mytopic2", "message with expired attachment") + m.ID = "m4" + m.SequenceID = "m4" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &model.Attachment{ + Name: "expired-car.jpg", + Type: "image/jpeg", + Size: 20000, + Expires: time.Now().Add(-1 * time.Hour).Unix(), + URL: "https://ntfy.sh/file/aCaRURL.jpg", + } + require.Nil(t, s.AddMessage(m)) + + ids, err := s.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, 1, len(ids)) + require.Equal(t, "m4", ids[0]) +} + +func testSender(t *testing.T, s message.Store) { + m1 := model.NewDefaultMessage("mytopic", "mymessage") + m1.Sender = netip.MustParseAddr("1.2.3.4") + require.Nil(t, s.AddMessage(m1)) + + m2 := model.NewDefaultMessage("mytopic", "mymessage without sender") + require.Nil(t, s.AddMessage(m2)) + + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4")) + require.Equal(t, messages[1].Sender, netip.Addr{}) +} + +func testDeleteScheduledBySequenceID(t *testing.T, s message.Store) { + // Create a scheduled (unpublished) message + scheduledMsg := model.NewDefaultMessage("mytopic", "scheduled message") + scheduledMsg.ID = "scheduled1" + scheduledMsg.SequenceID = "seq123" + scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled + require.Nil(t, s.AddMessage(scheduledMsg)) + + // Create a published message with different sequence ID + publishedMsg := model.NewDefaultMessage("mytopic", "published message") + publishedMsg.ID = "published1" + publishedMsg.SequenceID = "seq456" + publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published + require.Nil(t, s.AddMessage(publishedMsg)) + + // Create a scheduled message in a different topic + otherTopicMsg := model.NewDefaultMessage("othertopic", "other scheduled") + otherTopicMsg.ID = "other1" + otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg + otherTopicMsg.Time = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(otherTopicMsg)) + + // Verify all messages exist (including scheduled) + messages, err := s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + + messages, err = s.Messages("othertopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + + // Delete scheduled message by sequence ID and verify returned IDs + deletedIDs, err := s.DeleteScheduledBySequenceID("mytopic", "seq123") + require.Nil(t, err) + require.Equal(t, 1, len(deletedIDs)) + require.Equal(t, "scheduled1", deletedIDs[0]) + + // Verify scheduled message is deleted + messages, err = s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "published message", messages[0].Message) + + // Verify other topic's message still exists (topic-scoped deletion) + messages, err = s.Messages("othertopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "other scheduled", messages[0].Message) + + // Deleting non-existent sequence ID should return empty list + deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "nonexistent") + require.Nil(t, err) + require.Empty(t, deletedIDs) + + // Deleting published message should not affect it (only deletes unpublished) + deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "seq456") + require.Nil(t, err) + require.Empty(t, deletedIDs) + + messages, err = s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "published message", messages[0].Message) +} + +func testMessageByID(t *testing.T, s message.Store) { + // Add a message + m := model.NewDefaultMessage("mytopic", "some message") + m.Title = "some title" + m.Priority = 4 + m.Tags = []string{"tag1", "tag2"} + require.Nil(t, s.AddMessage(m)) + + // Retrieve by ID + retrieved, err := s.Message(m.ID) + require.Nil(t, err) + require.Equal(t, m.ID, retrieved.ID) + require.Equal(t, "mytopic", retrieved.Topic) + require.Equal(t, "some message", retrieved.Message) + require.Equal(t, "some title", retrieved.Title) + require.Equal(t, 4, retrieved.Priority) + require.Equal(t, []string{"tag1", "tag2"}, retrieved.Tags) + + // Non-existent ID returns ErrMessageNotFound + _, err = s.Message("doesnotexist") + require.Equal(t, model.ErrMessageNotFound, err) +} + +func testMarkPublished(t *testing.T, s message.Store) { + // Add a scheduled message (future time → unpublished) + m := model.NewDefaultMessage("mytopic", "scheduled message") + m.Time = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m)) + + // Verify it does not appear in non-scheduled queries + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 0, len(messages)) + + // Verify it does appear in scheduled queries + messages, err = s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + + // Mark as published + require.Nil(t, s.MarkPublished(m)) + + // Now it should appear in non-scheduled queries too + messages, err = s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "scheduled message", messages[0].Message) +} + +func testExpireMessages(t *testing.T, s message.Store) { + // Add messages to two topics + m1 := model.NewDefaultMessage("topic1", "message 1") + m1.Expires = time.Now().Add(time.Hour).Unix() + m2 := model.NewDefaultMessage("topic1", "message 2") + m2.Expires = time.Now().Add(time.Hour).Unix() + m3 := model.NewDefaultMessage("topic2", "message 3") + m3.Expires = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m3)) + + // Verify all messages exist + messages, err := s.Messages("topic1", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + messages, err = s.Messages("topic2", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + + // Expire topic1 messages + require.Nil(t, s.ExpireMessages("topic1")) + + // topic1 messages should now be expired (expires set to past) + expiredIDs, err := s.MessagesExpired() + require.Nil(t, err) + require.Equal(t, 2, len(expiredIDs)) + sort.Strings(expiredIDs) + expectedIDs := []string{m1.ID, m2.ID} + sort.Strings(expectedIDs) + require.Equal(t, expectedIDs, expiredIDs) + + // topic2 should be unaffected + messages, err = s.Messages("topic2", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "message 3", messages[0].Message) +} + +func testMarkAttachmentsDeleted(t *testing.T, s message.Store) { + // Add a message with an expired attachment (file needs cleanup) + m1 := model.NewDefaultMessage("mytopic", "old file") + m1.ID = "msg1" + m1.SequenceID = "msg1" + m1.Expires = time.Now().Add(time.Hour).Unix() + m1.Attachment = &model.Attachment{ + Name: "old.pdf", + Type: "application/pdf", + Size: 50000, + Expires: time.Now().Add(-time.Hour).Unix(), // Expired + URL: "https://ntfy.sh/file/old.pdf", + } + require.Nil(t, s.AddMessage(m1)) + + // Add a message with another expired attachment + m2 := model.NewDefaultMessage("mytopic", "another old file") + m2.ID = "msg2" + m2.SequenceID = "msg2" + m2.Expires = time.Now().Add(time.Hour).Unix() + m2.Attachment = &model.Attachment{ + Name: "another.pdf", + Type: "application/pdf", + Size: 30000, + Expires: time.Now().Add(-time.Hour).Unix(), // Expired + URL: "https://ntfy.sh/file/another.pdf", + } + require.Nil(t, s.AddMessage(m2)) + + // Both should show as expired attachments needing cleanup + ids, err := s.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, 2, len(ids)) + + // Mark msg1's attachment as deleted (file cleaned up) + require.Nil(t, s.MarkAttachmentsDeleted("msg1")) + + // Now only msg2 should show as needing cleanup + ids, err = s.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, 1, len(ids)) + require.Equal(t, "msg2", ids[0]) + + // Mark msg2 too + require.Nil(t, s.MarkAttachmentsDeleted("msg2")) + + // No more expired attachments to clean up + ids, err = s.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, 0, len(ids)) + + // Messages themselves still exist + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) +} + +func testStats(t *testing.T, s message.Store) { + // Initial stats should be zero + messages, err := s.Stats() + require.Nil(t, err) + require.Equal(t, int64(0), messages) + + // Update stats + require.Nil(t, s.UpdateStats(42)) + messages, err = s.Stats() + require.Nil(t, err) + require.Equal(t, int64(42), messages) + + // Update again (overwrites) + require.Nil(t, s.UpdateStats(100)) + messages, err = s.Stats() + require.Nil(t, err) + require.Equal(t, int64(100), messages) +} + +func testAddMessages(t *testing.T, s message.Store) { + // Batch add multiple messages + msgs := []*model.Message{ + model.NewDefaultMessage("mytopic", "batch 1"), + model.NewDefaultMessage("mytopic", "batch 2"), + model.NewDefaultMessage("othertopic", "batch 3"), + } + require.Nil(t, s.AddMessages(msgs)) + + // Verify all were inserted + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + + messages, err = s.Messages("othertopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "batch 3", messages[0].Message) + + // Empty batch should succeed + require.Nil(t, s.AddMessages([]*model.Message{})) + + // Batch with invalid event type should fail + badMsgs := []*model.Message{ + model.NewKeepaliveMessage("mytopic"), + } + require.NotNil(t, s.AddMessages(badMsgs)) +} + +func testMessagesDue(t *testing.T, s message.Store) { + // Add a message scheduled in the past (i.e. it's due now) + m1 := model.NewDefaultMessage("mytopic", "due message") + m1.Time = time.Now().Add(-time.Second).Unix() + // Set expires in the future so it doesn't get pruned + m1.Expires = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m1)) + + // Add a message scheduled in the future (not due) + m2 := model.NewDefaultMessage("mytopic", "future message") + m2.Time = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m2)) + + // Mark m1 as published so it won't be "due" + // (MessagesDue returns unpublished messages whose time <= now) + // m1 is auto-published (time <= now), so it should not be due + // m2 is unpublished (time in future), not due yet + due, err := s.MessagesDue() + require.Nil(t, err) + require.Equal(t, 0, len(due)) + + // Add a message that was explicitly scheduled in the past but time has "arrived" + // We need to manipulate the database to create a truly "due" message: + // a message with published=false and time <= now + m3 := model.NewDefaultMessage("mytopic", "truly due message") + m3.Time = time.Now().Add(2 * time.Second).Unix() // 2 seconds from now + require.Nil(t, s.AddMessage(m3)) + + // Not due yet + due, err = s.MessagesDue() + require.Nil(t, err) + require.Equal(t, 0, len(due)) + + // Wait for it to become due + time.Sleep(3 * time.Second) + + due, err = s.MessagesDue() + require.Nil(t, err) + require.Equal(t, 1, len(due)) + require.Equal(t, "truly due message", due[0].Message) +} + +func testMessageFieldRoundTrip(t *testing.T, s message.Store) { + // Create a message with all fields populated + m := model.NewDefaultMessage("mytopic", "hello world") + m.SequenceID = "custom_seq_id" + m.Title = "A Title" + m.Priority = 4 + m.Tags = []string{"warning", "srv01"} + m.Click = "https://example.com/click" + m.Icon = "https://example.com/icon.png" + m.Actions = []*model.Action{ + { + ID: "action1", + Action: "view", + Label: "Open Site", + URL: "https://example.com", + Clear: true, + }, + { + ID: "action2", + Action: "http", + Label: "Call Webhook", + URL: "https://example.com/hook", + Method: "PUT", + Headers: map[string]string{"X-Token": "secret"}, + Body: `{"key":"value"}`, + }, + } + m.ContentType = "text/markdown" + m.Encoding = "base64" + m.Sender = netip.MustParseAddr("9.8.7.6") + m.User = "u_TestUser123" + require.Nil(t, s.AddMessage(m)) + + // Retrieve and verify every field + retrieved, err := s.Message(m.ID) + require.Nil(t, err) + + require.Equal(t, m.ID, retrieved.ID) + require.Equal(t, "custom_seq_id", retrieved.SequenceID) + require.Equal(t, m.Time, retrieved.Time) + require.Equal(t, m.Expires, retrieved.Expires) + require.Equal(t, model.MessageEvent, retrieved.Event) + require.Equal(t, "mytopic", retrieved.Topic) + require.Equal(t, "hello world", retrieved.Message) + require.Equal(t, "A Title", retrieved.Title) + require.Equal(t, 4, retrieved.Priority) + require.Equal(t, []string{"warning", "srv01"}, retrieved.Tags) + require.Equal(t, "https://example.com/click", retrieved.Click) + require.Equal(t, "https://example.com/icon.png", retrieved.Icon) + require.Equal(t, "text/markdown", retrieved.ContentType) + require.Equal(t, "base64", retrieved.Encoding) + require.Equal(t, netip.MustParseAddr("9.8.7.6"), retrieved.Sender) + require.Equal(t, "u_TestUser123", retrieved.User) + + // Verify actions round-trip + require.Equal(t, 2, len(retrieved.Actions)) + + require.Equal(t, "action1", retrieved.Actions[0].ID) + require.Equal(t, "view", retrieved.Actions[0].Action) + require.Equal(t, "Open Site", retrieved.Actions[0].Label) + require.Equal(t, "https://example.com", retrieved.Actions[0].URL) + require.Equal(t, true, retrieved.Actions[0].Clear) + + require.Equal(t, "action2", retrieved.Actions[1].ID) + require.Equal(t, "http", retrieved.Actions[1].Action) + require.Equal(t, "Call Webhook", retrieved.Actions[1].Label) + require.Equal(t, "https://example.com/hook", retrieved.Actions[1].URL) + require.Equal(t, "PUT", retrieved.Actions[1].Method) + require.Equal(t, "secret", retrieved.Actions[1].Headers["X-Token"]) + require.Equal(t, `{"key":"value"}`, retrieved.Actions[1].Body) +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 00000000..8431554d --- /dev/null +++ b/model/model.go @@ -0,0 +1,204 @@ +package model + +import ( + "errors" + "net/netip" + "time" + + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/util" +) + +// List of possible events +const ( + OpenEvent = "open" + KeepaliveEvent = "keepalive" + MessageEvent = "message" + MessageDeleteEvent = "message_delete" + MessageClearEvent = "message_clear" + PollRequestEvent = "poll_request" +) + +const ( + MessageIDLength = 12 +) + +var ( + ErrUnexpectedMessageType = errors.New("unexpected message type") + ErrMessageNotFound = errors.New("message not found") +) + +// Message represents a message published to a topic +type Message struct { + ID string `json:"id"` // Random message ID + SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) + Time int64 `json:"time"` // Unix time in seconds + Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) + Event string `json:"event"` // One of the above + Topic string `json:"topic"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + Priority int `json:"priority,omitempty"` + Tags []string `json:"tags,omitempty"` + Click string `json:"click,omitempty"` + Icon string `json:"icon,omitempty"` + Actions []*Action `json:"actions,omitempty"` + Attachment *Attachment `json:"attachment,omitempty"` + PollID string `json:"poll_id,omitempty"` + ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown + Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes + Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting + User string `json:"-"` // UserID of the uploader, used to associated attachments +} + +// Context returns a log context for the message +func (m *Message) Context() log.Context { + fields := map[string]any{ + "topic": m.Topic, + "message_id": m.ID, + "message_sequence_id": m.SequenceID, + "message_time": m.Time, + "message_event": m.Event, + "message_body_size": len(m.Message), + } + if m.Sender.IsValid() { + fields["message_sender"] = m.Sender.String() + } + if m.User != "" { + fields["message_user"] = m.User + } + return fields +} + +// ForJSON returns a copy of the message suitable for JSON output. +// It clears the SequenceID if it equals the ID to reduce redundancy. +func (m *Message) ForJSON() *Message { + if m.SequenceID == m.ID { + clone := *m + clone.SequenceID = "" + return &clone + } + return m +} + +// Attachment represents a file attachment on a message +type Attachment struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` + Expires int64 `json:"expires,omitempty"` + URL string `json:"url"` +} + +// Action represents a user-defined action on a message +type Action struct { + ID string `json:"id"` + Action string `json:"action"` // "view", "broadcast", "http", or "copy" + Label string `json:"label"` // action button label + Clear bool `json:"clear"` // clear notification after successful execution + URL string `json:"url,omitempty"` // used in "view" and "http" actions + Method string `json:"method,omitempty"` // used in "http" action, default is POST (!) + Headers map[string]string `json:"headers,omitempty"` // used in "http" action + Body string `json:"body,omitempty"` // used in "http" action + Intent string `json:"intent,omitempty"` // used in "broadcast" action + Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action + Value string `json:"value,omitempty"` // used in "copy" action +} + +// NewAction creates a new action with initialized maps +func NewAction() *Action { + return &Action{ + Headers: make(map[string]string), + Extras: make(map[string]string), + } +} + +// NewMessage creates a new message with the current timestamp +func NewMessage(event, topic, msg string) *Message { + return &Message{ + ID: util.RandomString(MessageIDLength), + Time: time.Now().Unix(), + Event: event, + Topic: topic, + Message: msg, + } +} + +// NewOpenMessage is a convenience method to create an open message +func NewOpenMessage(topic string) *Message { + return NewMessage(OpenEvent, topic, "") +} + +// NewKeepaliveMessage is a convenience method to create a keepalive message +func NewKeepaliveMessage(topic string) *Message { + return NewMessage(KeepaliveEvent, topic, "") +} + +// NewDefaultMessage is a convenience method to create a notification message +func NewDefaultMessage(topic, msg string) *Message { + return NewMessage(MessageEvent, topic, msg) +} + +// NewActionMessage creates a new action message (message_delete or message_clear) +func NewActionMessage(event, topic, sequenceID string) *Message { + m := NewMessage(event, topic, "") + m.SequenceID = sequenceID + return m +} + +// ValidMessageID returns true if the given string is a valid message ID +func ValidMessageID(s string) bool { + return util.ValidRandomString(s, MessageIDLength) +} + +// SinceMarker represents a point in time or message ID from which to retrieve messages +type SinceMarker struct { + time time.Time + id string +} + +// NewSinceTime creates a new SinceMarker from a Unix timestamp +func NewSinceTime(timestamp int64) SinceMarker { + return SinceMarker{time.Unix(timestamp, 0), ""} +} + +// NewSinceID creates a new SinceMarker from a message ID +func NewSinceID(id string) SinceMarker { + return SinceMarker{time.Unix(0, 0), id} +} + +// IsAll returns true if this is the "all messages" marker +func (t SinceMarker) IsAll() bool { + return t == SinceAllMessages +} + +// IsNone returns true if this is the "no messages" marker +func (t SinceMarker) IsNone() bool { + return t == SinceNoMessages +} + +// IsLatest returns true if this is the "latest message" marker +func (t SinceMarker) IsLatest() bool { + return t == SinceLatestMessage +} + +// IsID returns true if this marker references a specific message ID +func (t SinceMarker) IsID() bool { + return t.id != "" && t.id != "latest" +} + +// Time returns the time component of the marker +func (t SinceMarker) Time() time.Time { + return t.time +} + +// ID returns the message ID component of the marker +func (t SinceMarker) ID() string { + return t.id +} + +var ( + SinceAllMessages = SinceMarker{time.Unix(0, 0), ""} + SinceNoMessages = SinceMarker{time.Unix(1, 0), ""} + SinceLatestMessage = SinceMarker{time.Unix(0, 0), "latest"} +) diff --git a/server/actions.go b/server/actions.go index 7bd4c903..dc4a6b43 100644 --- a/server/actions.go +++ b/server/actions.go @@ -8,6 +8,7 @@ import ( "strings" "unicode/utf8" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/util" ) @@ -39,7 +40,7 @@ type actionParser struct { // parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons. // It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON), // and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple). -func parseActions(s string) (actions []*action, err error) { +func parseActions(s string) (actions []*model.Action, err error) { // Parse JSON or simple format s = strings.TrimSpace(s) if strings.HasPrefix(s, "[") { @@ -80,8 +81,8 @@ func parseActions(s string) (actions []*action, err error) { } // parseActionsFromJSON converts a JSON array into an array of actions -func parseActionsFromJSON(s string) ([]*action, error) { - actions := make([]*action, 0) +func parseActionsFromJSON(s string) ([]*model.Action, error) { + actions := make([]*model.Action, 0) if err := json.Unmarshal([]byte(s), &actions); err != nil { return nil, fmt.Errorf("JSON error: %w", err) } @@ -107,7 +108,7 @@ func parseActionsFromJSON(s string) ([]*action, error) { // https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go // https://github.com/benbjohnson/sql-parser/blob/master/scanner.go // https://blog.gopheracademy.com/advent-2014/parsers-lexers/ -func parseActionsFromSimple(s string) ([]*action, error) { +func parseActionsFromSimple(s string) ([]*model.Action, error) { if !utf8.ValidString(s) { return nil, errors.New("invalid utf-8 string") } @@ -119,8 +120,8 @@ func parseActionsFromSimple(s string) ([]*action, error) { } // Parse loops trough parseAction() until the end of the string is reached -func (p *actionParser) Parse() ([]*action, error) { - actions := make([]*action, 0) +func (p *actionParser) Parse() ([]*model.Action, error) { + actions := make([]*model.Action, 0) for !p.eof() { a, err := p.parseAction() if err != nil { @@ -134,7 +135,7 @@ func (p *actionParser) Parse() ([]*action, error) { // parseAction parses the individual sections of an action using parseSection into key/value pairs, // and then uses populateAction to interpret the keys/values. The function terminates // when EOF or ";" is reached. -func (p *actionParser) parseAction() (*action, error) { +func (p *actionParser) parseAction() (*model.Action, error) { a := newAction() section := 0 for { @@ -155,7 +156,7 @@ func (p *actionParser) parseAction() (*action, error) { // populateAction is the "business logic" of the parser. It applies the key/value // pair to the action instance. -func populateAction(newAction *action, section int, key, value string) error { +func populateAction(newAction *model.Action, section int, key, value string) error { // Auto-expand keys based on their index if key == "" && section == 0 { key = "action" diff --git a/server/log.go b/server/log.go index 72eaf4d1..03600c0d 100644 --- a/server/log.go +++ b/server/log.go @@ -10,6 +10,7 @@ import ( "github.com/emersion/go-smtp" "github.com/gorilla/websocket" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/util" ) @@ -55,12 +56,12 @@ func logvr(v *visitor, r *http.Request) *log.Event { } // logvrm creates a new log event with HTTP request, visitor fields and message fields -func logvrm(v *visitor, r *http.Request, m *message) *log.Event { +func logvrm(v *visitor, r *http.Request, m *model.Message) *log.Event { return logvr(v, r).With(m) } // logvrm creates a new log event with visitor fields and message fields -func logvm(v *visitor, m *message) *log.Event { +func logvm(v *visitor, m *model.Message) *log.Event { return logv(v).With(m) } diff --git a/server/message_cache.go b/server/message_cache.go deleted file mode 100644 index 84083aee..00000000 --- a/server/message_cache.go +++ /dev/null @@ -1,1104 +0,0 @@ -package server - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "net/netip" - "path/filepath" - "strings" - "sync" - "time" - - _ "github.com/mattn/go-sqlite3" // SQLite driver - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/util" -) - -var ( - errUnexpectedMessageType = errors.New("unexpected message type") - errMessageNotFound = errors.New("message not found") - errNoRows = errors.New("no rows found") -) - -// Messages cache -const ( - createMessagesTableQuery = ` - BEGIN; - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - mid TEXT NOT NULL, - sequence_id TEXT NOT NULL, - time INT NOT NULL, - event TEXT NOT NULL, - expires INT 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 INT NOT NULL, - attachment_expires INT NOT NULL, - attachment_url TEXT NOT NULL, - attachment_deleted INT NOT NULL, - sender TEXT NOT NULL, - user TEXT NOT NULL, - content_type TEXT NOT NULL, - encoding TEXT NOT NULL, - published INT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); - CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); - CREATE INDEX IF NOT EXISTS idx_time ON messages (time); - CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); - CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); - CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); - CREATE INDEX IF NOT EXISTS idx_user ON messages (user); - CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); - CREATE TABLE IF NOT EXISTS stats ( - key TEXT PRIMARY KEY, - value INT - ); - INSERT INTO stats (key, value) VALUES ('messages', 0); - COMMIT; - ` - insertMessageQuery = ` - INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` - selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` - deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` - updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` - selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics - selectMessagesByIDQuery = ` - 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, content_type, encoding - FROM messages - WHERE mid = ? - ` - selectMessagesSinceTimeQuery = ` - 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, content_type, encoding - FROM messages - WHERE topic = ? AND time >= ? AND published = 1 - ORDER BY time, id - ` - selectMessagesSinceTimeIncludeScheduledQuery = ` - 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, content_type, encoding - FROM messages - WHERE topic = ? AND time >= ? - ORDER BY time, id - ` - selectMessagesSinceIDQuery = ` - 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, content_type, encoding - FROM messages - WHERE topic = ? AND id > ? AND published = 1 - ORDER BY time, id - ` - selectMessagesSinceIDIncludeScheduledQuery = ` - 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, content_type, encoding - FROM messages - WHERE topic = ? AND (id > ? OR published = 0) - ORDER BY time, id - ` - selectMessagesLatestQuery = ` - 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, content_type, encoding - FROM messages - WHERE topic = ? AND published = 1 - ORDER BY time DESC, id DESC - LIMIT 1 - ` - selectMessagesDueQuery = ` - 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, content_type, encoding - FROM messages - WHERE time <= ? AND published = 0 - ORDER BY time, id - ` - selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` - updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` - selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` - selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` - selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` - - updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` - selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` - selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?` - selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?` - - selectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` - updateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` -) - -// Schema management queries -const ( - currentSchemaVersion = 14 - createSchemaVersionTableQuery = ` - CREATE TABLE IF NOT EXISTS schemaVersion ( - id INT PRIMARY KEY, - version INT NOT NULL - ); - ` - insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` - selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` - - // 0 -> 1 - migrate0To1AlterMessagesTableQuery = ` - BEGIN; - ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0); - ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT(''); - COMMIT; - ` - - // 1 -> 2 - migrate1To2AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1); - ` - - // 2 -> 3 - migrate2To3AlterMessagesTableQuery = ` - BEGIN; - ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0'); - ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0'); - ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT(''); - COMMIT; - ` - // 3 -> 4 - migrate3To4AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT(''); - ` - - // 4 -> 5 - migrate4To5AlterMessagesTableQuery = ` - BEGIN; - CREATE TABLE IF NOT EXISTS messages_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - mid TEXT NOT NULL, - time INT 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, - attachment_name TEXT NOT NULL, - attachment_type TEXT NOT NULL, - attachment_size INT NOT NULL, - attachment_expires INT NOT NULL, - attachment_url TEXT NOT NULL, - attachment_owner TEXT NOT NULL, - encoding TEXT NOT NULL, - published INT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid); - CREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic); - INSERT - INTO messages_new ( - mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, - attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published) - SELECT - id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, - attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published - FROM messages; - DROP TABLE messages; - ALTER TABLE messages_new RENAME TO messages; - COMMIT; - ` - - // 5 -> 6 - migrate5To6AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT(''); - ` - - // 6 -> 7 - migrate6To7AlterMessagesTableQuery = ` - ALTER TABLE messages RENAME COLUMN attachment_owner TO sender; - ` - - // 7 -> 8 - migrate7To8AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT(''); - ` - - // 8 -> 9 - migrate8To9AlterMessagesTableQuery = ` - CREATE INDEX IF NOT EXISTS idx_time ON messages (time); - ` - - // 9 -> 10 - migrate9To10AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0'); - ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0'); - CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); - CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); - CREATE INDEX IF NOT EXISTS idx_user ON messages (user); - CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); - ` - migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?` - - // 10 -> 11 - migrate10To11AlterMessagesTableQuery = ` - CREATE TABLE IF NOT EXISTS stats ( - key TEXT PRIMARY KEY, - value INT - ); - INSERT INTO stats (key, value) VALUES ('messages', 0); - ` - - // 11 -> 12 - migrate11To12AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT(''); - ` - - // 12 -> 13 - migrate12To13AlterMessagesTableQuery = ` - CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); - ` - - //13 -> 14 - migrate13To14AlterMessagesTableQuery = ` - ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message'); - CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); - ` -) - -var ( - migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{ - 0: migrateFrom0, - 1: migrateFrom1, - 2: migrateFrom2, - 3: migrateFrom3, - 4: migrateFrom4, - 5: migrateFrom5, - 6: migrateFrom6, - 7: migrateFrom7, - 8: migrateFrom8, - 9: migrateFrom9, - 10: migrateFrom10, - 11: migrateFrom11, - 12: migrateFrom12, - 13: migrateFrom13, - } -) - -type messageCache struct { - db *sql.DB - queue *util.BatchingQueue[*message] - nop bool - mu sync.Mutex -} - -// newSqliteCache creates a SQLite file-backed cache -func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) { - // Check the parent directory of the database file (makes for friendly error messages) - parentDir := filepath.Dir(filename) - if !util.FileExists(parentDir) { - return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir) - } - // Open database - db, err := sql.Open("sqlite3", filename) - if err != nil { - return nil, err - } - if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil { - return nil, err - } - var queue *util.BatchingQueue[*message] - if batchSize > 0 || batchTimeout > 0 { - queue = util.NewBatchingQueue[*message](batchSize, batchTimeout) - } - cache := &messageCache{ - db: db, - queue: queue, - nop: nop, - } - go cache.processMessageBatches() - return cache, nil -} - -// newMemCache creates an in-memory cache -func newMemCache() (*messageCache, error) { - return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, false) -} - -// newNopCache creates an in-memory cache that discards all messages; -// it is always empty and can be used if caching is entirely disabled -func newNopCache() (*messageCache, error) { - return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, true) -} - -// createMemoryFilename creates a unique memory filename to use for the SQLite backend. -// From mattn/go-sqlite3: "Each connection to ":memory:" opens a brand new in-memory -// sql database, so if the stdlib's sql engine happens to open another connection and -// you've only specified ":memory:", that connection will see a brand new database. -// A workaround is to use "file::memory:?cache=shared" (or "file:foobar?mode=memory&cache=shared"). -// Every connection to this string will point to the same in-memory database." -func createMemoryFilename() string { - return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10)) -} - -// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asyncronously. -// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor. -func (c *messageCache) AddMessage(m *message) error { - if c.queue != nil { - c.queue.Enqueue(m) - return nil - } - return c.addMessages([]*message{m}) -} - -// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until -// SQLite's busy_timeout is exceeded before erroring out. -func (c *messageCache) addMessages(ms []*message) error { - c.mu.Lock() - defer c.mu.Unlock() - if c.nop { - return nil - } - if len(ms) == 0 { - return nil - } - start := time.Now() - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - stmt, err := tx.Prepare(insertMessageQuery) - if err != nil { - return err - } - defer stmt.Close() - for _, m := range ms { - if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent { - return errUnexpectedMessageType - } - published := m.Time <= time.Now().Unix() - tags := strings.Join(m.Tags, ",") - var attachmentName, attachmentType, attachmentURL string - var attachmentSize, attachmentExpires, attachmentDeleted int64 - if m.Attachment != nil { - attachmentName = m.Attachment.Name - attachmentType = m.Attachment.Type - attachmentSize = m.Attachment.Size - attachmentExpires = m.Attachment.Expires - attachmentURL = m.Attachment.URL - } - var actionsStr string - if len(m.Actions) > 0 { - actionsBytes, err := json.Marshal(m.Actions) - if err != nil { - return err - } - actionsStr = string(actionsBytes) - } - var sender string - if m.Sender.IsValid() { - sender = m.Sender.String() - } - _, err := stmt.Exec( - m.ID, - m.SequenceID, - m.Time, - m.Event, - m.Expires, - m.Topic, - m.Message, - m.Title, - m.Priority, - tags, - m.Click, - m.Icon, - actionsStr, - attachmentName, - attachmentType, - attachmentSize, - attachmentExpires, - attachmentURL, - attachmentDeleted, // Always zero - sender, - m.User, - m.ContentType, - m.Encoding, - published, - ) - if err != nil { - return err - } - } - if err := tx.Commit(); err != nil { - log.Tag(tagMessageCache).Err(err).Error("Writing %d message(s) failed (took %v)", len(ms), time.Since(start)) - return err - } - log.Tag(tagMessageCache).Debug("Wrote %d message(s) in %v", len(ms), time.Since(start)) - return nil -} - -func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) { - if since.IsNone() { - return make([]*message, 0), nil - } else if since.IsLatest() { - return c.messagesLatest(topic) - } else if since.IsID() { - return c.messagesSinceID(topic, since, scheduled) - } - return c.messagesSinceTime(topic, since, scheduled) -} - -func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, scheduled bool) ([]*message, error) { - var rows *sql.Rows - var err error - if scheduled { - rows, err = c.db.Query(selectMessagesSinceTimeIncludeScheduledQuery, topic, since.Time().Unix()) - } else { - rows, err = c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix()) - } - if err != nil { - return nil, err - } - return readMessages(rows) -} - -func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) { - idrows, err := c.db.Query(selectRowIDFromMessageID, since.ID()) - if err != nil { - return nil, err - } - defer idrows.Close() - if !idrows.Next() { - return c.messagesSinceTime(topic, sinceAllMessages, scheduled) - } - var rowID int64 - if err := idrows.Scan(&rowID); err != nil { - return nil, err - } - idrows.Close() - var rows *sql.Rows - if scheduled { - rows, err = c.db.Query(selectMessagesSinceIDIncludeScheduledQuery, topic, rowID) - } else { - rows, err = c.db.Query(selectMessagesSinceIDQuery, topic, rowID) - } - if err != nil { - return nil, err - } - return readMessages(rows) -} - -func (c *messageCache) messagesLatest(topic string) ([]*message, error) { - rows, err := c.db.Query(selectMessagesLatestQuery, topic) - if err != nil { - return nil, err - } - return readMessages(rows) -} - -func (c *messageCache) MessagesDue() ([]*message, error) { - rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix()) - if err != nil { - return nil, err - } - return readMessages(rows) -} - -// MessagesExpired returns a list of IDs for messages that have expires (should be deleted) -func (c *messageCache) MessagesExpired() ([]string, error) { - rows, err := c.db.Query(selectMessagesExpiredQuery, time.Now().Unix()) - if err != nil { - return nil, err - } - defer rows.Close() - ids := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return ids, nil -} - -func (c *messageCache) Message(id string) (*message, error) { - rows, err := c.db.Query(selectMessagesByIDQuery, id) - if err != nil { - return nil, err - } - if !rows.Next() { - return nil, errMessageNotFound - } - defer rows.Close() - return readMessage(rows) -} - -func (c *messageCache) MarkPublished(m *message) error { - c.mu.Lock() - defer c.mu.Unlock() - _, err := c.db.Exec(updateMessagePublishedQuery, m.ID) - return err -} - -func (c *messageCache) MessageCounts() (map[string]int, error) { - rows, err := c.db.Query(selectMessageCountPerTopicQuery) - if err != nil { - return nil, err - } - defer rows.Close() - var topic string - var count int - counts := make(map[string]int) - for rows.Next() { - if err := rows.Scan(&topic, &count); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - counts[topic] = count - } - return counts, nil -} - -func (c *messageCache) Topics() (map[string]*topic, error) { - rows, err := c.db.Query(selectTopicsQuery) - if err != nil { - return nil, err - } - defer rows.Close() - topics := make(map[string]*topic) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - topics[id] = newTopic(id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return topics, nil -} - -func (c *messageCache) DeleteMessages(ids ...string) error { - c.mu.Lock() - defer c.mu.Unlock() - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, id := range ids { - if _, err := tx.Exec(deleteMessageQuery, id); err != nil { - return err - } - } - return tx.Commit() -} - -// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID. -// It returns the message IDs of the deleted messages, which can be used to clean up attachment files. -func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) { - c.mu.Lock() - defer c.mu.Unlock() - tx, err := c.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - // First, get the message IDs of scheduled messages to be deleted - rows, err := tx.Query(selectScheduledMessageIDsBySeqIDQuery, topic, sequenceID) - if err != nil { - return nil, err - } - defer rows.Close() - ids := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - rows.Close() // Close rows before executing delete in same transaction - // Then delete the messages - if _, err := tx.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID); err != nil { - return nil, err - } - if err := tx.Commit(); err != nil { - return nil, err - } - return ids, nil -} - -func (c *messageCache) ExpireMessages(topics ...string) error { - c.mu.Lock() - defer c.mu.Unlock() - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, t := range topics { - if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil { - return err - } - } - return tx.Commit() -} - -func (c *messageCache) AttachmentsExpired() ([]string, error) { - rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix()) - if err != nil { - return nil, err - } - defer rows.Close() - ids := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return ids, nil -} - -func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error { - c.mu.Lock() - defer c.mu.Unlock() - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, id := range ids { - if _, err := tx.Exec(updateAttachmentDeleted, id); err != nil { - return err - } - } - return tx.Commit() -} - -func (c *messageCache) AttachmentBytesUsedBySender(sender string) (int64, error) { - rows, err := c.db.Query(selectAttachmentsSizeBySenderQuery, sender, time.Now().Unix()) - if err != nil { - return 0, err - } - return c.readAttachmentBytesUsed(rows) -} - -func (c *messageCache) AttachmentBytesUsedByUser(userID string) (int64, error) { - rows, err := c.db.Query(selectAttachmentsSizeByUserIDQuery, userID, time.Now().Unix()) - if err != nil { - return 0, err - } - return c.readAttachmentBytesUsed(rows) -} - -func (c *messageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) { - defer rows.Close() - var size int64 - if !rows.Next() { - return 0, errors.New("no rows found") - } - if err := rows.Scan(&size); err != nil { - return 0, err - } else if err := rows.Err(); err != nil { - return 0, err - } - return size, nil -} - -func (c *messageCache) processMessageBatches() { - if c.queue == nil { - return - } - for messages := range c.queue.Dequeue() { - if err := c.addMessages(messages); err != nil { - log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch") - } - } -} - -func readMessages(rows *sql.Rows) ([]*message, error) { - defer rows.Close() - messages := make([]*message, 0) - for rows.Next() { - m, err := readMessage(rows) - if err != nil { - return nil, err - } - messages = append(messages, m) - } - if err := rows.Err(); err != nil { - return nil, err - } - return messages, nil -} - -func readMessage(rows *sql.Rows) (*message, error) { - var timestamp, expires, attachmentSize, attachmentExpires int64 - var priority int - var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string - err := rows.Scan( - &id, - &sequenceID, - ×tamp, - &event, - &expires, - &topic, - &msg, - &title, - &priority, - &tagsStr, - &click, - &icon, - &actionsStr, - &attachmentName, - &attachmentType, - &attachmentSize, - &attachmentExpires, - &attachmentURL, - &sender, - &user, - &contentType, - &encoding, - ) - if err != nil { - return nil, err - } - var tags []string - if tagsStr != "" { - tags = strings.Split(tagsStr, ",") - } - var actions []*action - if actionsStr != "" { - if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil { - return nil, err - } - } - senderIP, err := netip.ParseAddr(sender) - if err != nil { - senderIP = netip.Addr{} // if no IP stored in database, return invalid address - } - var att *attachment - if attachmentName != "" && attachmentURL != "" { - att = &attachment{ - Name: attachmentName, - Type: attachmentType, - Size: attachmentSize, - Expires: attachmentExpires, - URL: attachmentURL, - } - } - return &message{ - ID: id, - SequenceID: sequenceID, - Time: timestamp, - Expires: expires, - Event: event, - Topic: topic, - Message: msg, - Title: title, - Priority: priority, - Tags: tags, - Click: click, - Icon: icon, - Actions: actions, - Attachment: att, - Sender: senderIP, // Must parse assuming database must be correct - User: user, - ContentType: contentType, - Encoding: encoding, - }, nil -} - -func (c *messageCache) UpdateStats(messages int64) error { - c.mu.Lock() - defer c.mu.Unlock() - _, err := c.db.Exec(updateStatsQuery, messages) - return err -} - -func (c *messageCache) Stats() (messages int64, err error) { - rows, err := c.db.Query(selectStatsQuery) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - if err := rows.Scan(&messages); err != nil { - return 0, err - } - return messages, nil -} - -func (c *messageCache) Close() error { - return c.db.Close() -} - -func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error { - // Run startup queries - if startupQueries != "" { - if _, err := db.Exec(startupQueries); err != nil { - return err - } - } - - // If 'messages' table does not exist, this must be a new database - rowsMC, err := db.Query(selectMessagesCountQuery) - if err != nil { - return setupNewCacheDB(db) - } - rowsMC.Close() - - // If 'messages' table exists, check 'schemaVersion' table - schemaVersion := 0 - rowsSV, err := db.Query(selectSchemaVersionQuery) - if err == nil { - defer rowsSV.Close() - if !rowsSV.Next() { - return errors.New("cannot determine schema version: cache file may be corrupt") - } - if err := rowsSV.Scan(&schemaVersion); err != nil { - return err - } - rowsSV.Close() - } - - // Do migrations - if schemaVersion == currentSchemaVersion { - return nil - } else if schemaVersion > currentSchemaVersion { - return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion) - } - for i := schemaVersion; i < currentSchemaVersion; i++ { - fn, ok := migrations[i] - if !ok { - return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1) - } else if err := fn(db, cacheDuration); err != nil { - return err - } - } - return nil -} - -func setupNewCacheDB(db *sql.DB) error { - if _, err := db.Exec(createMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(createSchemaVersionTableQuery); err != nil { - return err - } - if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil { - return err - } - return nil -} - -func migrateFrom0(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1") - if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(createSchemaVersionTableQuery); err != nil { - return err - } - if _, err := db.Exec(insertSchemaVersion, 1); err != nil { - return err - } - return nil -} - -func migrateFrom1(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2") - if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(updateSchemaVersion, 2); err != nil { - return err - } - return nil -} - -func migrateFrom2(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3") - if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(updateSchemaVersion, 3); err != nil { - return err - } - return nil -} - -func migrateFrom3(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4") - if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(updateSchemaVersion, 4); err != nil { - return err - } - return nil -} - -func migrateFrom4(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5") - if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(updateSchemaVersion, 5); err != nil { - return err - } - return nil -} - -func migrateFrom5(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6") - if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(updateSchemaVersion, 6); err != nil { - return err - } - return nil -} - -func migrateFrom6(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7") - if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(updateSchemaVersion, 7); err != nil { - return err - } - return nil -} - -func migrateFrom7(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8") - if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(updateSchemaVersion, 8); err != nil { - return err - } - return nil -} - -func migrateFrom8(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9") - if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(updateSchemaVersion, 9); err != nil { - return err - } - return nil -} - -func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate9To10AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(migrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 10); err != nil { - return err - } - return tx.Commit() -} - -func migrateFrom10(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate10To11AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 11); err != nil { - return err - } - return tx.Commit() -} - -func migrateFrom11(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate11To12AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 12); err != nil { - return err - } - return tx.Commit() -} - -func migrateFrom12(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate12To13AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 13); err != nil { - return err - } - return tx.Commit() -} - -func migrateFrom13(db *sql.DB, _ time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate13To14AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 14); err != nil { - return err - } - return tx.Commit() -} diff --git a/server/message_cache_test.go b/server/message_cache_test.go deleted file mode 100644 index 672f91b0..00000000 --- a/server/message_cache_test.go +++ /dev/null @@ -1,825 +0,0 @@ -package server - -import ( - "database/sql" - "fmt" - "github.com/stretchr/testify/assert" - "net/netip" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestSqliteCache_Messages(t *testing.T) { - testCacheMessages(t, newSqliteTestCache(t)) -} - -func TestMemCache_Messages(t *testing.T) { - testCacheMessages(t, newMemTestCache(t)) -} - -func testCacheMessages(t *testing.T, c *messageCache) { - m1 := newDefaultMessage("mytopic", "my message") - m1.Time = 1 - - m2 := newDefaultMessage("mytopic", "my other message") - m2.Time = 2 - - require.Nil(t, c.AddMessage(m1)) - require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message"))) - require.Nil(t, c.AddMessage(m2)) - - // Adding invalid - require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added! - require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added! - - // mytopic: count - counts, err := c.MessageCounts() - require.Nil(t, err) - require.Equal(t, 2, counts["mytopic"]) - - // mytopic: since all - messages, _ := c.Messages("mytopic", sinceAllMessages, false) - require.Equal(t, 2, len(messages)) - require.Equal(t, "my message", messages[0].Message) - require.Equal(t, "mytopic", messages[0].Topic) - require.Equal(t, messageEvent, messages[0].Event) - require.Equal(t, "", messages[0].Title) - require.Equal(t, 0, messages[0].Priority) - require.Nil(t, messages[0].Tags) - require.Equal(t, "my other message", messages[1].Message) - - // mytopic: since none - messages, _ = c.Messages("mytopic", sinceNoMessages, false) - require.Empty(t, messages) - - // mytopic: since m1 (by ID) - messages, _ = c.Messages("mytopic", newSinceID(m1.ID), false) - require.Equal(t, 1, len(messages)) - require.Equal(t, m2.ID, messages[0].ID) - require.Equal(t, "my other message", messages[0].Message) - require.Equal(t, "mytopic", messages[0].Topic) - - // mytopic: since 2 - messages, _ = c.Messages("mytopic", newSinceTime(2), false) - require.Equal(t, 1, len(messages)) - require.Equal(t, "my other message", messages[0].Message) - - // mytopic: latest - messages, _ = c.Messages("mytopic", sinceLatestMessage, false) - require.Equal(t, 1, len(messages)) - require.Equal(t, "my other message", messages[0].Message) - - // example: count - counts, err = c.MessageCounts() - require.Nil(t, err) - require.Equal(t, 1, counts["example"]) - - // example: since all - messages, _ = c.Messages("example", sinceAllMessages, false) - require.Equal(t, "my example message", messages[0].Message) - - // non-existing: count - counts, err = c.MessageCounts() - require.Nil(t, err) - require.Equal(t, 0, counts["doesnotexist"]) - - // non-existing: since all - messages, _ = c.Messages("doesnotexist", sinceAllMessages, false) - require.Empty(t, messages) -} - -func TestSqliteCache_MessagesLock(t *testing.T) { - testCacheMessagesLock(t, newSqliteTestCache(t)) -} - -func TestMemCache_MessagesLock(t *testing.T) { - testCacheMessagesLock(t, newMemTestCache(t)) -} - -func testCacheMessagesLock(t *testing.T, c *messageCache) { - var wg sync.WaitGroup - for i := 0; i < 5000; i++ { - wg.Add(1) - go func() { - assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message"))) - wg.Done() - }() - } - wg.Wait() -} - -func TestSqliteCache_MessagesScheduled(t *testing.T) { - testCacheMessagesScheduled(t, newSqliteTestCache(t)) -} - -func TestMemCache_MessagesScheduled(t *testing.T) { - testCacheMessagesScheduled(t, newMemTestCache(t)) -} - -func testCacheMessagesScheduled(t *testing.T, c *messageCache) { - m1 := newDefaultMessage("mytopic", "message 1") - m2 := newDefaultMessage("mytopic", "message 2") - m2.Time = time.Now().Add(time.Hour).Unix() - m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! - m4 := newDefaultMessage("mytopic2", "message 4") - m4.Time = time.Now().Add(time.Minute).Unix() - require.Nil(t, c.AddMessage(m1)) - require.Nil(t, c.AddMessage(m2)) - require.Nil(t, c.AddMessage(m3)) - - messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled - require.Equal(t, 1, len(messages)) - require.Equal(t, "message 1", messages[0].Message) - - messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled - require.Equal(t, 3, len(messages)) - require.Equal(t, "message 1", messages[0].Message) - require.Equal(t, "message 3", messages[1].Message) // Order! - require.Equal(t, "message 2", messages[2].Message) - - messages, _ = c.MessagesDue() - require.Empty(t, messages) -} - -func TestSqliteCache_Topics(t *testing.T) { - testCacheTopics(t, newSqliteTestCache(t)) -} - -func TestMemCache_Topics(t *testing.T) { - testCacheTopics(t, newMemTestCache(t)) -} - -func testCacheTopics(t *testing.T, c *messageCache) { - require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message"))) - require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1"))) - require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2"))) - require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3"))) - - topics, err := c.Topics() - if err != nil { - t.Fatal(err) - } - require.Equal(t, 2, len(topics)) - require.Equal(t, "topic1", topics["topic1"].ID) - require.Equal(t, "topic2", topics["topic2"].ID) -} - -func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) { - testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t)) -} - -func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) { - testCacheMessagesTagsPrioAndTitle(t, newMemTestCache(t)) -} - -func testCacheMessagesTagsPrioAndTitle(t *testing.T, c *messageCache) { - m := newDefaultMessage("mytopic", "some message") - m.Tags = []string{"tag1", "tag2"} - m.Priority = 5 - m.Title = "some title" - require.Nil(t, c.AddMessage(m)) - - messages, _ := c.Messages("mytopic", sinceAllMessages, false) - require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags) - require.Equal(t, 5, messages[0].Priority) - require.Equal(t, "some title", messages[0].Title) -} - -func TestSqliteCache_MessagesSinceID(t *testing.T) { - testCacheMessagesSinceID(t, newSqliteTestCache(t)) -} - -func TestMemCache_MessagesSinceID(t *testing.T) { - testCacheMessagesSinceID(t, newMemTestCache(t)) -} - -func testCacheMessagesSinceID(t *testing.T, c *messageCache) { - m1 := newDefaultMessage("mytopic", "message 1") - m1.Time = 100 - m2 := newDefaultMessage("mytopic", "message 2") - m2.Time = 200 - m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 - m4 := newDefaultMessage("mytopic", "message 4") - m4.Time = 400 - m5 := newDefaultMessage("mytopic", "message 5") - m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 - m6 := newDefaultMessage("mytopic", "message 6") - m6.Time = 600 - m7 := newDefaultMessage("mytopic", "message 7") - m7.Time = 700 - - require.Nil(t, c.AddMessage(m1)) - require.Nil(t, c.AddMessage(m2)) - require.Nil(t, c.AddMessage(m3)) - require.Nil(t, c.AddMessage(m4)) - require.Nil(t, c.AddMessage(m5)) - require.Nil(t, c.AddMessage(m6)) - require.Nil(t, c.AddMessage(m7)) - - // Case 1: Since ID exists, exclude scheduled - messages, _ := c.Messages("mytopic", newSinceID(m2.ID), false) - require.Equal(t, 3, len(messages)) - require.Equal(t, "message 4", messages[0].Message) - require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5! - require.Equal(t, "message 7", messages[2].Message) - - // Case 2: Since ID exists, include scheduled - messages, _ = c.Messages("mytopic", newSinceID(m2.ID), true) - require.Equal(t, 5, len(messages)) - require.Equal(t, "message 4", messages[0].Message) - require.Equal(t, "message 6", messages[1].Message) - require.Equal(t, "message 7", messages[2].Message) - require.Equal(t, "message 5", messages[3].Message) // Order! - require.Equal(t, "message 3", messages[4].Message) // Order! - - // Case 3: Since ID does not exist (-> Return all messages), include scheduled - messages, _ = c.Messages("mytopic", newSinceID("doesntexist"), true) - require.Equal(t, 7, len(messages)) - require.Equal(t, "message 1", messages[0].Message) - require.Equal(t, "message 2", messages[1].Message) - require.Equal(t, "message 4", messages[2].Message) - require.Equal(t, "message 6", messages[3].Message) - require.Equal(t, "message 7", messages[4].Message) - require.Equal(t, "message 5", messages[5].Message) // Order! - require.Equal(t, "message 3", messages[6].Message) // Order! - - // Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled - messages, _ = c.Messages("mytopic", newSinceID(m7.ID), false) - require.Equal(t, 0, len(messages)) - - // Case 5: Since ID exists and is last message (-> Return no messages), include scheduled - messages, _ = c.Messages("mytopic", newSinceID(m7.ID), true) - require.Equal(t, 2, len(messages)) - require.Equal(t, "message 5", messages[0].Message) - require.Equal(t, "message 3", messages[1].Message) -} - -func TestSqliteCache_Prune(t *testing.T) { - testCachePrune(t, newSqliteTestCache(t)) -} - -func TestMemCache_Prune(t *testing.T) { - testCachePrune(t, newMemTestCache(t)) -} - -func testCachePrune(t *testing.T, c *messageCache) { - now := time.Now().Unix() - - m1 := newDefaultMessage("mytopic", "my message") - m1.Time = now - 10 - m1.Expires = now - 5 - - m2 := newDefaultMessage("mytopic", "my other message") - m2.Time = now - 5 - m2.Expires = now + 5 // In the future - - m3 := newDefaultMessage("another_topic", "and another one") - m3.Time = now - 12 - m3.Expires = now - 2 - - require.Nil(t, c.AddMessage(m1)) - require.Nil(t, c.AddMessage(m2)) - require.Nil(t, c.AddMessage(m3)) - - counts, err := c.MessageCounts() - require.Nil(t, err) - require.Equal(t, 2, counts["mytopic"]) - require.Equal(t, 1, counts["another_topic"]) - - expiredMessageIDs, err := c.MessagesExpired() - require.Nil(t, err) - require.Nil(t, c.DeleteMessages(expiredMessageIDs...)) - - counts, err = c.MessageCounts() - require.Nil(t, err) - require.Equal(t, 1, counts["mytopic"]) - require.Equal(t, 0, counts["another_topic"]) - - messages, err := c.Messages("mytopic", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "my other message", messages[0].Message) -} - -func TestSqliteCache_Attachments(t *testing.T) { - testCacheAttachments(t, newSqliteTestCache(t)) -} - -func TestMemCache_Attachments(t *testing.T) { - testCacheAttachments(t, newMemTestCache(t)) -} - -func testCacheAttachments(t *testing.T, c *messageCache) { - expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired - m := newDefaultMessage("mytopic", "flower for you") - m.ID = "m1" - m.SequenceID = "m1" - m.Sender = netip.MustParseAddr("1.2.3.4") - m.Attachment = &attachment{ - Name: "flower.jpg", - Type: "image/jpeg", - Size: 5000, - Expires: expires1, - URL: "https://ntfy.sh/file/AbDeFgJhal.jpg", - } - require.Nil(t, c.AddMessage(m)) - - expires2 := time.Now().Add(2 * time.Hour).Unix() // Future - m = newDefaultMessage("mytopic", "sending you a car") - m.ID = "m2" - m.SequenceID = "m2" - m.Sender = netip.MustParseAddr("1.2.3.4") - m.Attachment = &attachment{ - Name: "car.jpg", - Type: "image/jpeg", - Size: 10000, - Expires: expires2, - URL: "https://ntfy.sh/file/aCaRURL.jpg", - } - require.Nil(t, c.AddMessage(m)) - - expires3 := time.Now().Add(1 * time.Hour).Unix() // Future - m = newDefaultMessage("another-topic", "sending you another car") - m.ID = "m3" - m.SequenceID = "m3" - m.User = "u_BAsbaAa" - m.Sender = netip.MustParseAddr("5.6.7.8") - m.Attachment = &attachment{ - Name: "another-car.jpg", - Type: "image/jpeg", - Size: 20000, - Expires: expires3, - URL: "https://ntfy.sh/file/zakaDHFW.jpg", - } - require.Nil(t, c.AddMessage(m)) - - messages, err := c.Messages("mytopic", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) - - require.Equal(t, "flower for you", messages[0].Message) - require.Equal(t, "flower.jpg", messages[0].Attachment.Name) - require.Equal(t, "image/jpeg", messages[0].Attachment.Type) - require.Equal(t, int64(5000), messages[0].Attachment.Size) - require.Equal(t, expires1, messages[0].Attachment.Expires) - require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) - require.Equal(t, "1.2.3.4", messages[0].Sender.String()) - - require.Equal(t, "sending you a car", messages[1].Message) - require.Equal(t, "car.jpg", messages[1].Attachment.Name) - require.Equal(t, "image/jpeg", messages[1].Attachment.Type) - require.Equal(t, int64(10000), messages[1].Attachment.Size) - require.Equal(t, expires2, messages[1].Attachment.Expires) - require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) - require.Equal(t, "1.2.3.4", messages[1].Sender.String()) - - size, err := c.AttachmentBytesUsedBySender("1.2.3.4") - require.Nil(t, err) - require.Equal(t, int64(10000), size) - - size, err = c.AttachmentBytesUsedBySender("5.6.7.8") - require.Nil(t, err) - require.Equal(t, int64(0), size) // Accounted to the user, not the IP! - - size, err = c.AttachmentBytesUsedByUser("u_BAsbaAa") - require.Nil(t, err) - require.Equal(t, int64(20000), size) -} - -func TestSqliteCache_Attachments_Expired(t *testing.T) { - testCacheAttachmentsExpired(t, newSqliteTestCache(t)) -} - -func TestMemCache_Attachments_Expired(t *testing.T) { - testCacheAttachmentsExpired(t, newMemTestCache(t)) -} - -func testCacheAttachmentsExpired(t *testing.T, c *messageCache) { - m := newDefaultMessage("mytopic", "flower for you") - m.ID = "m1" - m.SequenceID = "m1" - m.Expires = time.Now().Add(time.Hour).Unix() - require.Nil(t, c.AddMessage(m)) - - m = newDefaultMessage("mytopic", "message with attachment") - m.ID = "m2" - m.SequenceID = "m2" - m.Expires = time.Now().Add(2 * time.Hour).Unix() - m.Attachment = &attachment{ - Name: "car.jpg", - Type: "image/jpeg", - Size: 10000, - Expires: time.Now().Add(2 * time.Hour).Unix(), - URL: "https://ntfy.sh/file/aCaRURL.jpg", - } - require.Nil(t, c.AddMessage(m)) - - m = newDefaultMessage("mytopic", "message with external attachment") - m.ID = "m3" - m.SequenceID = "m3" - m.Expires = time.Now().Add(2 * time.Hour).Unix() - m.Attachment = &attachment{ - Name: "car.jpg", - Type: "image/jpeg", - Expires: 0, // Unknown! - URL: "https://somedomain.com/car.jpg", - } - require.Nil(t, c.AddMessage(m)) - - m = newDefaultMessage("mytopic2", "message with expired attachment") - m.ID = "m4" - m.SequenceID = "m4" - m.Expires = time.Now().Add(2 * time.Hour).Unix() - m.Attachment = &attachment{ - Name: "expired-car.jpg", - Type: "image/jpeg", - Size: 20000, - Expires: time.Now().Add(-1 * time.Hour).Unix(), - URL: "https://ntfy.sh/file/aCaRURL.jpg", - } - require.Nil(t, c.AddMessage(m)) - - ids, err := c.AttachmentsExpired() - require.Nil(t, err) - require.Equal(t, 1, len(ids)) - require.Equal(t, "m4", ids[0]) -} - -func TestSqliteCache_Migration_From0(t *testing.T) { - filename := newSqliteTestCacheFile(t) - db, err := sql.Open("sqlite3", filename) - require.Nil(t, err) - - // Create "version 0" schema - _, err = db.Exec(` - BEGIN; - CREATE TABLE IF NOT EXISTS messages ( - id VARCHAR(20) PRIMARY KEY, - time INT NOT NULL, - topic VARCHAR(64) NOT NULL, - message VARCHAR(1024) NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); - COMMIT; - `) - require.Nil(t, err) - - // Insert a bunch of messages - for i := 0; i < 10; i++ { - _, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`, - fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i)) - require.Nil(t, err) - } - require.Nil(t, db.Close()) - - // Create cache to trigger migration - c := newSqliteTestCacheFromFile(t, filename, "") - checkSchemaVersion(t, c.db) - - messages, err := c.Messages("mytopic", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 10, len(messages)) - require.Equal(t, "some message 5", messages[5].Message) - require.Equal(t, "", messages[5].Title) - require.Nil(t, messages[5].Tags) - require.Equal(t, 0, messages[5].Priority) -} - -func TestSqliteCache_Migration_From1(t *testing.T) { - filename := newSqliteTestCacheFile(t) - db, err := sql.Open("sqlite3", filename) - require.Nil(t, err) - - // Create "version 1" schema - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS messages ( - id VARCHAR(20) PRIMARY KEY, - time INT NOT NULL, - topic VARCHAR(64) NOT NULL, - message VARCHAR(512) NOT NULL, - title VARCHAR(256) NOT NULL, - priority INT NOT NULL, - tags VARCHAR(256) NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); - CREATE TABLE IF NOT EXISTS schemaVersion ( - id INT PRIMARY KEY, - version INT NOT NULL - ); - INSERT INTO schemaVersion (id, version) VALUES (1, 1); - `) - require.Nil(t, err) - - // Insert a bunch of messages - for i := 0; i < 10; i++ { - _, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`, - fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "") - require.Nil(t, err) - } - require.Nil(t, db.Close()) - - // Create cache to trigger migration - c := newSqliteTestCacheFromFile(t, filename, "") - checkSchemaVersion(t, c.db) - - // Add delayed message - delayedMessage := newDefaultMessage("mytopic", "some delayed message") - delayedMessage.Time = time.Now().Add(time.Minute).Unix() - require.Nil(t, c.AddMessage(delayedMessage)) - - // 10, not 11! - messages, err := c.Messages("mytopic", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 10, len(messages)) - - // 11! - messages, err = c.Messages("mytopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 11, len(messages)) - - // Check that index "idx_topic" exists - rows, err := c.db.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`) - require.Nil(t, err) - require.True(t, rows.Next()) - var indexName string - require.Nil(t, rows.Scan(&indexName)) - require.Equal(t, "idx_topic", indexName) -} - -func TestSqliteCache_Migration_From9(t *testing.T) { - // This primarily tests the awkward migration that introduces the "expires" column. - // The migration logic has to update the column, using the existing "cache-duration" value. - - filename := newSqliteTestCacheFile(t) - db, err := sql.Open("sqlite3", filename) - require.Nil(t, err) - - // Create "version 8" schema - _, err = db.Exec(` - BEGIN; - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - mid TEXT NOT NULL, - time INT 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 INT NOT NULL, - attachment_expires INT NOT NULL, - attachment_url TEXT NOT NULL, - sender TEXT NOT NULL, - encoding TEXT NOT NULL, - published INT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); - CREATE INDEX IF NOT EXISTS idx_time ON messages (time); - CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); - CREATE TABLE IF NOT EXISTS schemaVersion ( - id INT PRIMARY KEY, - version INT NOT NULL - ); - INSERT INTO schemaVersion (id, version) VALUES (1, 9); - COMMIT; - `) - require.Nil(t, err) - - // Insert a bunch of messages - insertQuery := ` - INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - for i := 0; i < 10; i++ { - _, err = db.Exec( - insertQuery, - fmt.Sprintf("abcd%d", i), - time.Now().Unix(), - "mytopic", - fmt.Sprintf("some message %d", i), - "", // title - 0, // priority - "", // tags - "", // click - "", // icon - "", // actions - "", // attachment_name - "", // attachment_type - 0, // attachment_size - 0, // attachment_type - "", // attachment_url - "9.9.9.9", // sender - "", // encoding - 1, // published - ) - require.Nil(t, err) - } - - // Create cache to trigger migration - cacheDuration := 17 * time.Hour - c, err := newSqliteCache(filename, "", cacheDuration, 0, 0, false) - require.Nil(t, err) - checkSchemaVersion(t, c.db) - - // Check version - rows, err := db.Query(`SELECT version FROM main.schemaVersion WHERE id = 1`) - require.Nil(t, err) - require.True(t, rows.Next()) - var version int - require.Nil(t, rows.Scan(&version)) - require.Equal(t, currentSchemaVersion, version) - - messages, err := c.Messages("mytopic", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 10, len(messages)) - for _, m := range messages { - require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix()) - require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix()) - } -} - -func TestSqliteCache_StartupQueries_WAL(t *testing.T) { - filename := newSqliteTestCacheFile(t) - startupQueries := `pragma journal_mode = WAL; -pragma synchronous = normal; -pragma temp_store = memory;` - db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false) - require.Nil(t, err) - require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message"))) - require.FileExists(t, filename) - require.FileExists(t, filename+"-wal") - require.FileExists(t, filename+"-shm") -} - -func TestSqliteCache_StartupQueries_None(t *testing.T) { - filename := newSqliteTestCacheFile(t) - startupQueries := "" - db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false) - require.Nil(t, err) - require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message"))) - require.FileExists(t, filename) - require.NoFileExists(t, filename+"-wal") - require.NoFileExists(t, filename+"-shm") -} - -func TestSqliteCache_StartupQueries_Fail(t *testing.T) { - filename := newSqliteTestCacheFile(t) - startupQueries := `xx error` - _, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false) - require.Error(t, err) -} - -func TestSqliteCache_Sender(t *testing.T) { - testSender(t, newSqliteTestCache(t)) -} - -func TestMemCache_Sender(t *testing.T) { - testSender(t, newMemTestCache(t)) -} - -func testSender(t *testing.T, c *messageCache) { - m1 := newDefaultMessage("mytopic", "mymessage") - m1.Sender = netip.MustParseAddr("1.2.3.4") - require.Nil(t, c.AddMessage(m1)) - - m2 := newDefaultMessage("mytopic", "mymessage without sender") - require.Nil(t, c.AddMessage(m2)) - - messages, err := c.Messages("mytopic", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) - require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4")) - require.Equal(t, messages[1].Sender, netip.Addr{}) -} - -func TestSqliteCache_DeleteScheduledBySequenceID(t *testing.T) { - testDeleteScheduledBySequenceID(t, newSqliteTestCache(t)) -} - -func TestMemCache_DeleteScheduledBySequenceID(t *testing.T) { - testDeleteScheduledBySequenceID(t, newMemTestCache(t)) -} - -func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) { - // Create a scheduled (unpublished) message - scheduledMsg := newDefaultMessage("mytopic", "scheduled message") - scheduledMsg.ID = "scheduled1" - scheduledMsg.SequenceID = "seq123" - scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled - require.Nil(t, c.AddMessage(scheduledMsg)) - - // Create a published message with different sequence ID - publishedMsg := newDefaultMessage("mytopic", "published message") - publishedMsg.ID = "published1" - publishedMsg.SequenceID = "seq456" - publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published - require.Nil(t, c.AddMessage(publishedMsg)) - - // Create a scheduled message in a different topic - otherTopicMsg := newDefaultMessage("othertopic", "other scheduled") - otherTopicMsg.ID = "other1" - otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg - otherTopicMsg.Time = time.Now().Add(time.Hour).Unix() - require.Nil(t, c.AddMessage(otherTopicMsg)) - - // Verify all messages exist (including scheduled) - messages, err := c.Messages("mytopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) - - messages, err = c.Messages("othertopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - - // Delete scheduled message by sequence ID and verify returned IDs - deletedIDs, err := c.DeleteScheduledBySequenceID("mytopic", "seq123") - require.Nil(t, err) - require.Equal(t, 1, len(deletedIDs)) - require.Equal(t, "scheduled1", deletedIDs[0]) - - // Verify scheduled message is deleted - messages, err = c.Messages("mytopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "published message", messages[0].Message) - - // Verify other topic's message still exists (topic-scoped deletion) - messages, err = c.Messages("othertopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "other scheduled", messages[0].Message) - - // Deleting non-existent sequence ID should return empty list - deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent") - require.Nil(t, err) - require.Empty(t, deletedIDs) - - // Deleting published message should not affect it (only deletes unpublished) - deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "seq456") - require.Nil(t, err) - require.Empty(t, deletedIDs) - - messages, err = c.Messages("mytopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "published message", messages[0].Message) -} - -func checkSchemaVersion(t *testing.T, db *sql.DB) { - rows, err := db.Query(`SELECT version FROM schemaVersion`) - require.Nil(t, err) - require.True(t, rows.Next()) - - var schemaVersion int - require.Nil(t, rows.Scan(&schemaVersion)) - require.Equal(t, currentSchemaVersion, schemaVersion) - require.Nil(t, rows.Close()) -} - -func TestMemCache_NopCache(t *testing.T) { - c, _ := newNopCache() - require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message"))) - - messages, err := c.Messages("mytopic", sinceAllMessages, false) - require.Nil(t, err) - require.Empty(t, messages) - - topics, err := c.Topics() - require.Nil(t, err) - require.Empty(t, topics) -} - -func newSqliteTestCache(t *testing.T) *messageCache { - c, err := newSqliteCache(newSqliteTestCacheFile(t), "", time.Hour, 0, 0, false) - if err != nil { - t.Fatal(err) - } - return c -} - -func newSqliteTestCacheFile(t *testing.T) string { - return filepath.Join(t.TempDir(), "cache.db") -} - -func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache { - c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false) - require.Nil(t, err) - return c -} - -func newMemTestCache(t *testing.T) *messageCache { - c, err := newMemCache() - require.Nil(t, err) - return c -} diff --git a/server/server.go b/server/server.go index ea03355b..80f367c2 100644 --- a/server/server.go +++ b/server/server.go @@ -33,6 +33,8 @@ import ( "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/message" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -57,7 +59,7 @@ type Server struct { messages int64 // Total number of messages (persisted if messageCache enabled) messagesHistory []int64 // Last n values of the messages counter, used to determine rate userManager *user.Manager // Might be nil! - messageCache *messageCache // Database that stores the messages + messageCache message.Store // Database that stores the messages webPush webpush.Store // Database that stores web push subscriptions fileCache *fileCache // File system based cache that stores attachments stripe stripeAPI // Stripe API, can be replaced with a mock @@ -188,10 +190,14 @@ func New(conf *Config) (*Server, error) { return nil, err } } - topics, err := messageCache.Topics() + topicIDs, err := messageCache.Topics() if err != nil { return nil, err } + topics := make(map[string]*topic, len(topicIDs)) + for _, id := range topicIDs { + topics[id] = newTopic(id) + } messages, err := messageCache.Stats() if err != nil { return nil, err @@ -263,13 +269,15 @@ func New(conf *Config) (*Server, error) { return s, nil } -func createMessageCache(conf *Config) (*messageCache, error) { +func createMessageCache(conf *Config) (message.Store, error) { if conf.CacheDuration == 0 { - return newNopCache() + return message.NewNopStore() + } else if conf.DatabaseURL != "" { + return message.NewPostgresStore(conf.DatabaseURL, conf.CacheBatchSize, conf.CacheBatchTimeout) } else if conf.CacheFile != "" { - return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false) + return message.NewSQLiteStore(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false) } - return newMemCache() + return message.NewMemStore() } // Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts @@ -750,7 +758,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) if s.config.CacheBatchTimeout > 0 { // Strange edge case: If we immediately after upload request the file (the web app does this for images), // and messages are persisted asynchronously, retry fetching from the database - m, err = util.Retry(func() (*message, error) { + m, err = util.Retry(func() (*model.Message, error) { return s.messageCache.Message(messageID) }, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond) } @@ -796,7 +804,7 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error { return writeMatrixDiscoveryResponse(w) } -func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) { +func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) { start := time.Now() t, err := fromContext[*topic](r, contextTopic) if err != nil { @@ -924,7 +932,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } minc(metricMessagesPublishedSuccess) - return s.writeJSON(w, m.forJSON()) + return s.writeJSON(w, m.ForJSON()) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -1014,10 +1022,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * s.mu.Lock() s.messages++ s.mu.Unlock() - return s.writeJSON(w, m.forJSON()) + return s.writeJSON(w, m.ForJSON()) } -func (s *Server) sendToFirebase(v *visitor, m *message) { +func (s *Server) sendToFirebase(v *visitor, m *model.Message) { logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") if err := s.firebaseClient.Send(v, m); err != nil { minc(metricFirebasePublishedFailure) @@ -1031,7 +1039,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) { minc(metricFirebasePublishedSuccess) } -func (s *Server) sendEmail(v *visitor, m *message, email string) { +func (s *Server) sendEmail(v *visitor, m *model.Message, email string) { logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email) if err := s.smtpSender.Send(v, m, email); err != nil { logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) @@ -1041,7 +1049,7 @@ func (s *Server) sendEmail(v *visitor, m *message, email string) { minc(metricEmailsPublishedSuccess) } -func (s *Server) forwardPollRequest(v *visitor, m *message) { +func (s *Server) forwardPollRequest(v *visitor, m *model.Message) { topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic) topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL))) forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash) @@ -1073,7 +1081,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, priorityStr string, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, priorityStr string, err *errHTTP) { if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) { pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path) if err != nil { @@ -1100,7 +1108,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi filename := readParam(r, "x-filename", "filename", "file", "f") attach := readParam(r, "x-attach", "attach", "a") if attach != "" || filename != "" { - m.Attachment = &attachment{} + m.Attachment = &model.Attachment{} } if filename != "" { m.Attachment.Name = filename @@ -1221,7 +1229,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // 7. curl -T file.txt ntfy.sh/mytopic // In all other cases, mostly if file.txt is > message limit, treat it as an attachment -func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error { +func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) } else if unifiedpush { @@ -1244,7 +1252,7 @@ func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error { return err } -func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error { +func (s *Server) handleBodyAsMessageAutoDetect(m *model.Message, body *util.PeekedReadCloser) error { if utf8.Valid(body.PeekedBytes) { m.Message = string(body.PeekedBytes) // Do not trim } else { @@ -1254,7 +1262,7 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead return nil } -func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error { +func (s *Server) handleBodyAsTextMessage(m *model.Message, body *util.PeekedReadCloser) error { if !utf8.Valid(body.PeekedBytes) { return errHTTPBadRequestMessageNotUTF8.With(m) } @@ -1267,7 +1275,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser return nil } -func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error { +func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error { body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit)) if err != nil { return err @@ -1292,7 +1300,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM // renderTemplateFromFile transforms the JSON message body according to a template from the filesystem. // The template file must be in the templates directory, or in the configured template directory. -func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody string) error { +func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error { if !templateNameRegex.MatchString(templateName) { return errHTTPBadRequestTemplateFileNotFound } @@ -1334,7 +1342,7 @@ func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody str // renderTemplateFromParams transforms the JSON message body according to the inline template in the // message, title, and priority parameters. -func (s *Server) renderTemplateFromParams(m *message, peekedBody string, priorityStr string) error { +func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error { var err error if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil { return err @@ -1375,7 +1383,7 @@ func (s *Server) renderTemplate(name, tpl, source string) (string, error) { return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines } -func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { +func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error { if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { return errHTTPBadRequestAttachmentsDisallowed.With(m) } @@ -1399,7 +1407,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, } } if m.Attachment == nil { - m.Attachment = &attachment{} + m.Attachment = &model.Attachment{} } var ext string m.Attachment.Expires = attachmentExpiry @@ -1426,9 +1434,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, } func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { - encoder := func(msg *message) (string, error) { + encoder := func(msg *model.Message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil { return "", err } return buf.String(), nil @@ -1437,9 +1445,9 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { - encoder := func(msg *message) (string, error) { + encoder := func(msg *model.Message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil { return "", err } if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent { @@ -1451,7 +1459,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v } func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *visitor) error { - encoder := func(msg *message) (string, error) { + encoder := func(msg *model.Message) (string, error) { if msg.Event == messageEvent { // only handle default events return strings.ReplaceAll(msg.Message, "\n", " ") + "\n", nil } @@ -1487,7 +1495,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * closed = true wlock.Unlock() }() - sub := func(v *visitor, msg *message) error { + sub := func(v *visitor, msg *model.Message) error { if !filters.Pass(msg) { return nil } @@ -1649,7 +1657,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi } } }) - sub := func(v *visitor, msg *message) error { + sub := func(v *visitor, msg *model.Message) error { if !filters.Pass(msg) { return nil } @@ -1696,7 +1704,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi return nil } -func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) { +func parseSubscribeParams(r *http.Request) (poll bool, since model.SinceMarker, scheduled bool, filters *queryFilter, err error) { poll = readBoolParam(r, false, "x-poll", "poll", "po") scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched") since, err = parseSince(r, poll) @@ -1777,11 +1785,11 @@ func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topi // sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the // marker, returning only messages that are newer than the marker. -func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error { +func (s *Server) sendOldMessages(topics []*topic, since model.SinceMarker, scheduled bool, v *visitor, sub subscriber) error { if since.IsNone() { return nil } - messages := make([]*message, 0) + messages := make([]*model.Message, 0) for _, t := range topics { topicMessages, err := s.messageCache.Messages(t.ID, since, scheduled) if err != nil { @@ -1804,7 +1812,7 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled b // // Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h), // "all" for all messages, or "latest" for the most recent message for a topic -func parseSince(r *http.Request, poll bool) (sinceMarker, error) { +func parseSince(r *http.Request, poll bool) (model.SinceMarker, error) { since := readParam(r, "x-since", "since", "si") // Easy cases (empty, all, none) @@ -2035,7 +2043,7 @@ func (s *Server) sendDelayedMessages() error { return nil } -func (s *Server) sendDelayedMessage(v *visitor, m *message) 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 diff --git a/server/server_firebase.go b/server/server_firebase.go index 9fde63a3..ad206e13 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -10,6 +10,7 @@ import ( "firebase.google.com/go/v4/messaging" "fmt" "google.golang.org/api/option" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" "strings" @@ -43,7 +44,7 @@ func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClien } } -func (c *firebaseClient) Send(v *visitor, m *message) error { +func (c *firebaseClient) Send(v *visitor, m *model.Message) error { if !v.FirebaseAllowed() { return errFirebaseTemporarilyBanned } @@ -121,7 +122,7 @@ func (c *firebaseSenderImpl) Send(m *messaging.Message) error { // On Android, this will trigger the app to poll the topic and thereby displaying new messages. // - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded // to Firebase here. This is mainly for iOS to support self-hosted servers. -func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, error) { +func toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message, error) { var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format var apnsConfig *messaging.APNSConfig switch m.Event { @@ -235,7 +236,7 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message { // createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS). // We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service // Extension in iOS can modify the message. -func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig { +func createAPNSAlertConfig(m *model.Message, data map[string]string) *messaging.APNSConfig { apnsData := make(map[string]any) for k, v := range data { apnsData[k] = v @@ -296,7 +297,7 @@ func maybeTruncateAPNSBodyMessage(s string) string { // // This empties all the fields that are not needed for a poll request and just sets the required fields, // most importantly, the PollID. -func toPollRequest(m *message) *message { +func toPollRequest(m *model.Message) *model.Message { pr := newPollRequestMessage(m.Topic, m.ID) pr.ID = m.ID pr.Time = m.Time diff --git a/server/server_firebase_dummy.go b/server/server_firebase_dummy.go index bddceff1..6b026075 100644 --- a/server/server_firebase_dummy.go +++ b/server/server_firebase_dummy.go @@ -4,6 +4,7 @@ package server import ( "errors" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" ) @@ -21,7 +22,7 @@ var ( type firebaseClient struct { } -func (c *firebaseClient) Send(v *visitor, m *message) error { +func (c *firebaseClient) Send(v *visitor, m *model.Message) error { return errFirebaseNotAvailable } diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index c98f528f..ab4b494c 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "net/netip" "strings" @@ -131,7 +132,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { m.Click = "https://google.com" m.Icon = "https://ntfy.sh/static/img/ntfy.png" m.Title = "some title" - m.Actions = []*action{ + m.Actions = []*model.Action{ { ID: "123", Action: "view", @@ -150,7 +151,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { }, }, } - m.Attachment = &attachment{ + m.Attachment = &model.Attachment{ Name: "some file.jpg", Type: "image/jpeg", Size: 12345, @@ -346,16 +347,16 @@ func TestToFirebaseSender_Abuse(t *testing.T) { client := newFirebaseClient(sender, &testAuther{}) visitor := newVisitor(newTestConfig(t), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil) - require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"})) + require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"})) require.Equal(t, 1, len(sender.Messages())) - require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"})) + require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"})) require.Equal(t, 2, len(sender.Messages())) - require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"})) + require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &model.Message{Topic: "mytopic"})) require.Equal(t, 2, len(sender.Messages())) sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working - require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"})) + require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &model.Message{Topic: "mytopic"})) require.Equal(t, 0, len(sender.Messages())) } diff --git a/server/server_test.go b/server/server_test.go index a44d3880..23f72d1c 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -25,6 +25,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/message" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -382,7 +384,7 @@ func TestServer_PublishAt(t *testing.T) { // Update message time to the past fakeTime := time.Now().Add(-10 * time.Second).Unix() - _, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime) + _, err := s.messageCache.DB().Exec(`UPDATE messages SET time=?`, fakeTime) require.Nil(t, err) // Trigger delayed message sending @@ -418,7 +420,7 @@ func TestServer_PublishAt_FromUser(t *testing.T) { // Update message time to the past fakeTime := time.Now().Add(-10 * time.Second).Unix() - _, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime) + _, err := s.messageCache.DB().Exec(`UPDATE messages SET time=?`, fakeTime) require.Nil(t, err) // Trigger delayed message sending @@ -596,8 +598,8 @@ func TestServer_PublishAndPollSince(t *testing.T) { require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code) } -func newMessageWithTimestamp(topic, message string, timestamp int64) *message { - m := newDefaultMessage(topic, message) +func newMessageWithTimestamp(topic, msg string, timestamp int64) *model.Message { + m := newDefaultMessage(topic, msg) m.Time = timestamp return m } @@ -1209,7 +1211,7 @@ type testMailer struct { mu sync.Mutex } -func (t *testMailer) Send(v *visitor, m *message, to string) error { +func (t *testMailer) Send(v *visitor, m *model.Message, to string) error { t.mu.Lock() defer t.mu.Unlock() t.count++ @@ -1414,7 +1416,7 @@ func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) { s := newTestServer(t, newTestConfig(t)) defer s.messageCache.Close() - subFn := func(v *visitor, msg *message) error { + subFn := func(v *visitor, msg *model.Message) error { return nil } @@ -2410,14 +2412,14 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { // Add lots of messages log.Info("Adding %d messages", count) start := time.Now() - messages := make([]*message, 0) + messages := make([]*model.Message, 0) for i := 0; i < count; i++ { topicID := fmt.Sprintf("topic%d", i) _, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array require.Nil(t, err) messages = append(messages, newDefaultMessage(topicID, "some message")) } - require.Nil(t, s.messageCache.addMessages(messages)) + require.Nil(t, s.messageCache.AddMessages(messages)) log.Info("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond)) // Update stats @@ -3763,6 +3765,12 @@ func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) { require.NoFileExists(t, attachmentFile) } +func newMemTestCache(t *testing.T) message.Store { + c, err := message.NewMemStore() + require.Nil(t, err) + return c +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" @@ -3829,8 +3837,8 @@ func subscribe(t *testing.T, s *Server, url string, rr *httptest.ResponseRecorde return cancelAndWaitForDone } -func toMessages(t *testing.T, s string) []*message { - messages := make([]*message, 0) +func toMessages(t *testing.T, s string) []*model.Message { + messages := make([]*model.Message, 0) scanner := bufio.NewScanner(strings.NewReader(s)) for scanner.Scan() { messages = append(messages, toMessage(t, scanner.Text())) @@ -3838,8 +3846,8 @@ func toMessages(t *testing.T, s string) []*message { return messages } -func toMessage(t *testing.T, s string) *message { - var m message +func toMessage(t *testing.T, s string) *model.Message { + var m model.Message require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&m)) return &m } diff --git a/server/server_twilio.go b/server/server_twilio.go index c1761613..8bce2b90 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -11,6 +11,7 @@ import ( "text/template" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -76,7 +77,7 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, * // callPhone calls the Twilio API to make a phone call to the given phone number, using the given message. // Failures will be logged, but not returned to the caller. -func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { +func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to string) { u, sender := v.User(), m.Sender.String() if u != nil { sender = u.Name diff --git a/server/server_webpush.go b/server/server_webpush.go index 11e37f66..f98b8e91 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -11,6 +11,7 @@ import ( "github.com/SherClockHolmes/webpush-go" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" wpush "heckel.io/ntfy/v2/webpush" ) @@ -83,14 +84,14 @@ func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ * return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { +func (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) { subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic) if err != nil { logvm(v, m).Err(err).With(v, m).Warn("Unable to publish web push messages") return } log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions)) - payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.forJSON())) + payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.ForJSON())) if err != nil { log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload") return diff --git a/server/server_webpush_dummy.go b/server/server_webpush_dummy.go index 425b22a6..70c2df22 100644 --- a/server/server_webpush_dummy.go +++ b/server/server_webpush_dummy.go @@ -4,6 +4,8 @@ package server import ( "net/http" + + "heckel.io/ntfy/v2/model" ) const ( @@ -20,7 +22,7 @@ func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ * return errHTTPNotFound } -func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { +func (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) { // Nothing to see here } diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 0f798030..4e5988ba 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -12,11 +12,12 @@ import ( "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/util" ) type mailer interface { - Send(v *visitor, m *message, to string) error + Send(v *visitor, m *model.Message, to string) error Counts() (total int64, success int64, failure int64) } @@ -27,7 +28,7 @@ type smtpSender struct { mu sync.Mutex } -func (s *smtpSender) Send(v *visitor, m *message, to string) error { +func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error { return s.withCount(v, m, func() error { host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr) if err != nil { @@ -63,7 +64,7 @@ func (s *smtpSender) Counts() (total int64, success int64, failure int64) { return s.success + s.failure, s.success, s.failure } -func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error { +func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error { err := fn() s.mu.Lock() defer s.mu.Unlock() @@ -76,7 +77,7 @@ func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error { return err } -func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) { +func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) { topicURL := baseURL + "/" + m.Topic subject := m.Title if subject == "" { diff --git a/server/smtp_sender_test.go b/server/smtp_sender_test.go index 782c0f97..4f97b128 100644 --- a/server/smtp_sender_test.go +++ b/server/smtp_sender_test.go @@ -1,12 +1,14 @@ package server import ( - "github.com/stretchr/testify/require" "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/model" ) func TestFormatMail_Basic(t *testing.T) { - actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{ + actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{ ID: "abc", Time: 1640382204, Event: "message", @@ -27,7 +29,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt } func TestFormatMail_JustEmojis(t *testing.T) { - actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{ + actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{ ID: "abc", Time: 1640382204, Event: "message", @@ -49,7 +51,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt } func TestFormatMail_JustOtherTags(t *testing.T) { - actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{ + actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{ ID: "abc", Time: 1640382204, Event: "message", @@ -73,7 +75,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt } func TestFormatMail_JustPriority(t *testing.T) { - actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{ + actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{ ID: "abc", Time: 1640382204, Event: "message", @@ -97,7 +99,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt } func TestFormatMail_UTF8Subject(t *testing.T) { - actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{ + actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{ ID: "abc", Time: 1640382204, Event: "message", @@ -119,7 +121,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt } func TestFormatMail_WithAllTheThings(t *testing.T) { - actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{ + actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{ ID: "abc", Time: 1640382204, Event: "message", diff --git a/server/smtp_server.go b/server/smtp_server.go index ee28efc2..e342e678 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -19,6 +19,7 @@ import ( "github.com/emersion/go-smtp" "github.com/microcosm-cc/bluemonday" + "heckel.io/ntfy/v2/model" ) var ( @@ -183,7 +184,7 @@ func (s *smtpSession) Data(r io.Reader) error { }) } -func (s *smtpSession) publishMessage(m *message) error { +func (s *smtpSession) publishMessage(m *model.Message) error { // Extract remote address (for rate limiting) remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String()) if err != nil { diff --git a/server/topic.go b/server/topic.go index 49def94b..f373a5e6 100644 --- a/server/topic.go +++ b/server/topic.go @@ -6,6 +6,7 @@ import ( "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/util" ) @@ -33,7 +34,7 @@ type topicSubscriber struct { } // subscriber is a function that is called for every new message on a topic -type subscriber func(v *visitor, msg *message) error +type subscriber func(v *visitor, msg *model.Message) error // newTopic creates a new topic func newTopic(id string) *topic { @@ -103,7 +104,7 @@ func (t *topic) Unsubscribe(id int) { } // Publish asynchronously publishes to all subscribers -func (t *topic) Publish(v *visitor, m *message) error { +func (t *topic) Publish(v *visitor, m *model.Message) error { go func() { // We want to lock the topic as short as possible, so we make a shallow copy of the // subscribers map here. Actually sending out the messages then doesn't have to lock. diff --git a/server/topic_test.go b/server/topic_test.go index 0376c942..f526921d 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -7,10 +7,11 @@ import ( "time" "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/model" ) func TestTopic_CancelSubscribersExceptUser(t *testing.T) { - subFn := func(v *visitor, msg *message) error { + subFn := func(v *visitor, msg *model.Message) error { return nil } canceled1 := atomic.Bool{} @@ -33,7 +34,7 @@ func TestTopic_CancelSubscribersExceptUser(t *testing.T) { func TestTopic_CancelSubscribersUser(t *testing.T) { t.Parallel() - subFn := func(v *visitor, msg *message) error { + subFn := func(v *visitor, msg *model.Message) error { return nil } canceled1 := atomic.Bool{} @@ -76,7 +77,7 @@ func TestTopic_Subscribe_DuplicateID(t *testing.T) { cancel: func() {}, } - subFn := func(v *visitor, msg *message) error { + subFn := func(v *visitor, msg *model.Message) error { return nil } diff --git a/server/types.go b/server/types.go index dfd797e5..ade191a2 100644 --- a/server/types.go +++ b/server/types.go @@ -2,109 +2,52 @@ package server import ( "net/http" - "net/netip" - "time" - "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) -// List of possible events +// Event constants const ( - openEvent = "open" - keepaliveEvent = "keepalive" - messageEvent = "message" - messageDeleteEvent = "message_delete" - messageClearEvent = "message_clear" - pollRequestEvent = "poll_request" + openEvent = model.OpenEvent + keepaliveEvent = model.KeepaliveEvent + messageEvent = model.MessageEvent + messageDeleteEvent = model.MessageDeleteEvent + messageClearEvent = model.MessageClearEvent + pollRequestEvent = model.PollRequestEvent + messageIDLength = model.MessageIDLength ) -const ( - messageIDLength = 12 +// Sentinel values and errors +var ( + sinceAllMessages = model.SinceAllMessages + sinceNoMessages = model.SinceNoMessages + sinceLatestMessage = model.SinceLatestMessage + errUnexpectedMessageType = model.ErrUnexpectedMessageType + errMessageNotFound = model.ErrMessageNotFound ) -// message represents a message published to a topic -type message struct { - ID string `json:"id"` // Random message ID - SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) - Time int64 `json:"time"` // Unix time in seconds - Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) - Event string `json:"event"` // One of the above - Topic string `json:"topic"` - Title string `json:"title,omitempty"` - Message string `json:"message,omitempty"` - Priority int `json:"priority,omitempty"` - Tags []string `json:"tags,omitempty"` - Click string `json:"click,omitempty"` - Icon string `json:"icon,omitempty"` - Actions []*action `json:"actions,omitempty"` - Attachment *attachment `json:"attachment,omitempty"` - PollID string `json:"poll_id,omitempty"` - ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown - Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes - Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting - User string `json:"-"` // UserID of the uploader, used to associated attachments -} +// Constructors and helpers +var ( + newMessage = model.NewMessage + newDefaultMessage = model.NewDefaultMessage + newOpenMessage = model.NewOpenMessage + newKeepaliveMessage = model.NewKeepaliveMessage + newActionMessage = model.NewActionMessage + newAction = model.NewAction + newSinceTime = model.NewSinceTime + newSinceID = model.NewSinceID + validMessageID = model.ValidMessageID +) -func (m *message) Context() log.Context { - fields := map[string]any{ - "topic": m.Topic, - "message_id": m.ID, - "message_sequence_id": m.SequenceID, - "message_time": m.Time, - "message_event": m.Event, - "message_body_size": len(m.Message), - } - if m.Sender.IsValid() { - fields["message_sender"] = m.Sender.String() - } - if m.User != "" { - fields["message_user"] = m.User - } - return fields -} - -// forJSON returns a copy of the message suitable for JSON output. -// It clears the SequenceID if it equals the ID to reduce redundancy. -func (m *message) forJSON() *message { - if m.SequenceID == m.ID { - clone := *m - clone.SequenceID = "" - return &clone - } +// newPollRequestMessage is a convenience method to create a poll request message +func newPollRequestMessage(topic, pollID string) *model.Message { + m := newMessage(pollRequestEvent, topic, newMessageBody) + m.PollID = pollID return m } -type attachment struct { - Name string `json:"name"` - Type string `json:"type,omitempty"` - Size int64 `json:"size,omitempty"` - Expires int64 `json:"expires,omitempty"` - URL string `json:"url"` -} - -type action struct { - ID string `json:"id"` - Action string `json:"action"` // "view", "broadcast", "http", or "copy" - Label string `json:"label"` // action button label - Clear bool `json:"clear"` // clear notification after successful execution - URL string `json:"url,omitempty"` // used in "view" and "http" actions - Method string `json:"method,omitempty"` // used in "http" action, default is POST (!) - Headers map[string]string `json:"headers,omitempty"` // used in "http" action - Body string `json:"body,omitempty"` // used in "http" action - Intent string `json:"intent,omitempty"` // used in "broadcast" action - Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action - Value string `json:"value,omitempty"` // used in "copy" action -} - -func newAction() *action { - return &action{ - Headers: make(map[string]string), - Extras: make(map[string]string), - } -} - // publishMessage is used as input when publishing as JSON type publishMessage struct { Topic string `json:"topic"` @@ -115,7 +58,7 @@ type publishMessage struct { Tags []string `json:"tags"` Click string `json:"click"` Icon string `json:"icon"` - Actions []action `json:"actions"` + Actions []model.Action `json:"actions"` Attach string `json:"attach"` Markdown bool `json:"markdown"` Filename string `json:"filename"` @@ -127,94 +70,7 @@ type publishMessage struct { } // messageEncoder is a function that knows how to encode a message -type messageEncoder func(msg *message) (string, error) - -// newMessage creates a new message with the current timestamp -func newMessage(event, topic, msg string) *message { - return &message{ - ID: util.RandomString(messageIDLength), - Time: time.Now().Unix(), - Event: event, - Topic: topic, - Message: msg, - } -} - -// newOpenMessage is a convenience method to create an open message -func newOpenMessage(topic string) *message { - return newMessage(openEvent, topic, "") -} - -// newKeepaliveMessage is a convenience method to create a keepalive message -func newKeepaliveMessage(topic string) *message { - return newMessage(keepaliveEvent, topic, "") -} - -// newDefaultMessage is a convenience method to create a notification message -func newDefaultMessage(topic, msg string) *message { - return newMessage(messageEvent, topic, msg) -} - -// newPollRequestMessage is a convenience method to create a poll request message -func newPollRequestMessage(topic, pollID string) *message { - m := newMessage(pollRequestEvent, topic, newMessageBody) - m.PollID = pollID - return m -} - -// newActionMessage creates a new action message (message_delete or message_clear) -func newActionMessage(event, topic, sequenceID string) *message { - m := newMessage(event, topic, "") - m.SequenceID = sequenceID - return m -} - -func validMessageID(s string) bool { - return util.ValidRandomString(s, messageIDLength) -} - -type sinceMarker struct { - time time.Time - id string -} - -func newSinceTime(timestamp int64) sinceMarker { - return sinceMarker{time.Unix(timestamp, 0), ""} -} - -func newSinceID(id string) sinceMarker { - return sinceMarker{time.Unix(0, 0), id} -} - -func (t sinceMarker) IsAll() bool { - return t == sinceAllMessages -} - -func (t sinceMarker) IsNone() bool { - return t == sinceNoMessages -} - -func (t sinceMarker) IsLatest() bool { - return t == sinceLatestMessage -} - -func (t sinceMarker) IsID() bool { - return t.id != "" && t.id != "latest" -} - -func (t sinceMarker) Time() time.Time { - return t.time -} - -func (t sinceMarker) ID() string { - return t.id -} - -var ( - sinceAllMessages = sinceMarker{time.Unix(0, 0), ""} - sinceNoMessages = sinceMarker{time.Unix(1, 0), ""} - sinceLatestMessage = sinceMarker{time.Unix(0, 0), "latest"} -) +type messageEncoder func(msg *model.Message) (string, error) type queryFilter struct { ID string @@ -246,7 +102,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { }, nil } -func (q *queryFilter) Pass(msg *message) bool { +func (q *queryFilter) Pass(msg *model.Message) bool { if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent { return true // filters only apply to messages } else if q.ID != "" && msg.ID != q.ID { @@ -572,10 +428,10 @@ const ( type webPushPayload struct { Event string `json:"event"` SubscriptionID string `json:"subscription_id"` - Message *message `json:"message"` + Message *model.Message `json:"message"` } -func newWebPushPayload(subscriptionID string, message *message) *webPushPayload { +func newWebPushPayload(subscriptionID string, message *model.Message) *webPushPayload { return &webPushPayload{ Event: webPushMessageEvent, SubscriptionID: subscriptionID, diff --git a/server/visitor.go b/server/visitor.go index f26729f1..12217be6 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -8,6 +8,7 @@ import ( "golang.org/x/time/rate" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -53,7 +54,7 @@ const ( // visitor represents an API user, and its associated rate.Limiter used for rate limiting type visitor struct { config *Config - messageCache *messageCache + messageCache message.Store userManager *user.Manager // May be nil ip netip.Addr // Visitor IP address user *user.User // Only set if authenticated user, otherwise nil @@ -114,7 +115,7 @@ const ( visitorLimitBasisTier = visitorLimitBasis("tier") ) -func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { +func newVisitor(conf *Config, messageCache message.Store, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { var messages, emails, calls int64 if user != nil { messages = user.Stats.Messages diff --git a/user/manager_test.go b/user/manager_test.go index 4b929d39..13cff861 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -691,7 +691,6 @@ func TestManager_Token_Expire(t *testing.T) { // But the token row should still exist tokens, err := a.Tokens(u.ID) require.Nil(t, err) - require.Equal(t, token1.Value, tokens[0].Value) require.Equal(t, 2, len(tokens)) // Expire tokens and check that token1 is gone From 0d1f3444f27000cbda102ca2bb64883a364b35dd Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 18 Feb 2026 20:48:41 -0500 Subject: [PATCH 197/548] fmt --- server/types.go | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/server/types.go b/server/types.go index ade191a2..6b5d9f5f 100644 --- a/server/types.go +++ b/server/types.go @@ -50,23 +50,23 @@ func newPollRequestMessage(topic, pollID string) *model.Message { // publishMessage is used as input when publishing as JSON type publishMessage struct { - Topic string `json:"topic"` - SequenceID string `json:"sequence_id"` - Title string `json:"title"` - Message string `json:"message"` - Priority int `json:"priority"` - Tags []string `json:"tags"` - Click string `json:"click"` - Icon string `json:"icon"` + Topic string `json:"topic"` + SequenceID string `json:"sequence_id"` + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + Tags []string `json:"tags"` + Click string `json:"click"` + Icon string `json:"icon"` Actions []model.Action `json:"actions"` - Attach string `json:"attach"` - Markdown bool `json:"markdown"` - Filename string `json:"filename"` - Email string `json:"email"` - Call string `json:"call"` - Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead) - Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead) - Delay string `json:"delay"` + Attach string `json:"attach"` + Markdown bool `json:"markdown"` + Filename string `json:"filename"` + Email string `json:"email"` + Call string `json:"call"` + Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead) + Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead) + Delay string `json:"delay"` } // messageEncoder is a function that knows how to encode a message @@ -426,8 +426,8 @@ const ( ) type webPushPayload struct { - Event string `json:"event"` - SubscriptionID string `json:"subscription_id"` + Event string `json:"event"` + SubscriptionID string `json:"subscription_id"` Message *model.Message `json:"message"` } From 9cc9891f49102a5abbe67e65a50646e36ea80118 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 18 Feb 2026 20:55:03 -0500 Subject: [PATCH 198/548] Add postgres to pipeline --- .github/workflows/release.yaml | 16 ++++++++++++++++ .github/workflows/test.yaml | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 70a70552..55a54d60 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,6 +6,22 @@ on: jobs: release: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: ntfy + POSTGRES_PASSWORD: ntfy + POSTGRES_DB: ntfy_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ntfy" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable" steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cfd9d754..44363d51 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,6 +3,22 @@ on: [ push, pull_request ] jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: ntfy + POSTGRES_PASSWORD: ntfy + POSTGRES_DB: ntfy_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ntfy" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable" steps: - name: Checkout code uses: actions/checkout@v3 From 939b3d1117484d99c77eacebaf8daf4a11ce5ef3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 18 Feb 2026 21:07:31 -0500 Subject: [PATCH 199/548] Fix lint, make pipeline use psotgres --- .github/workflows/test.yaml | 2 +- Makefile | 2 ++ model/model.go | 7 ++++--- server/types.go | 15 +++++++++------ user/util.go | 32 -------------------------------- 5 files changed, 16 insertions(+), 42 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 44363d51..2722d144 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -39,7 +39,7 @@ jobs: - name: Build web app (required for tests) run: make web - name: Run tests, formatting, vetting and linting - run: make check + run: make checkv - name: Run coverage run: make coverage - name: Upload coverage to codecov.io diff --git a/Makefile b/Makefile index 2da5333e..b6102814 100644 --- a/Makefile +++ b/Makefile @@ -265,6 +265,8 @@ cli-build-results: check: test web-fmt-check fmt-check vet web-lint lint staticcheck +checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck + test: .PHONY go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') diff --git a/model/model.go b/model/model.go index 8431554d..58e480d7 100644 --- a/model/model.go +++ b/model/model.go @@ -19,10 +19,10 @@ const ( PollRequestEvent = "poll_request" ) -const ( - MessageIDLength = 12 -) +// MessageIDLength is the length of a randomly generated message ID +const MessageIDLength = 12 +// Errors for message operations var ( ErrUnexpectedMessageType = errors.New("unexpected message type") ErrMessageNotFound = errors.New("message not found") @@ -197,6 +197,7 @@ func (t SinceMarker) ID() string { return t.id } +// Common SinceMarker values for subscribing to messages var ( SinceAllMessages = SinceMarker{time.Unix(0, 0), ""} SinceNoMessages = SinceMarker{time.Unix(1, 0), ""} diff --git a/server/types.go b/server/types.go index 6b5d9f5f..5e6ba2f3 100644 --- a/server/types.go +++ b/server/types.go @@ -19,13 +19,16 @@ const ( messageIDLength = model.MessageIDLength ) -// Sentinel values and errors +// SinceMarker aliases var ( - sinceAllMessages = model.SinceAllMessages - sinceNoMessages = model.SinceNoMessages - sinceLatestMessage = model.SinceLatestMessage - errUnexpectedMessageType = model.ErrUnexpectedMessageType - errMessageNotFound = model.ErrMessageNotFound + sinceAllMessages = model.SinceAllMessages + sinceNoMessages = model.SinceNoMessages + sinceLatestMessage = model.SinceLatestMessage +) + +// Error aliases +var ( + errMessageNotFound = model.ErrMessageNotFound ) // Constructors and helpers diff --git a/user/util.go b/user/util.go index 170b7717..16f6cc09 100644 --- a/user/util.go +++ b/user/util.go @@ -94,38 +94,6 @@ func nullInt64(v int64) sql.NullInt64 { return sql.NullInt64{Int64: v, Valid: true} } -// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back. -func execTx(db *sql.DB, f func(tx *sql.Tx) error) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := f(tx); err != nil { - return err - } - return tx.Commit() -} - -// queryTx executes a function in a transaction and returns the result. If the function -// returns an error, the transaction is rolled back. -func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) { - tx, err := db.Begin() - if err != nil { - var zero T - return zero, err - } - defer tx.Rollback() - t, err := f(tx) - if err != nil { - return t, err - } - if err := tx.Commit(); err != nil { - return t, err - } - return t, nil -} - // toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards, // and escapes '_', assuming '\' as escape character. func toSQLWildcard(s string) string { From 28e85df36e10abc5d0b86fc98522724754ec81b1 Mon Sep 17 00:00:00 2001 From: PanderMusubi Date: Thu, 19 Feb 2026 15:47:49 +0100 Subject: [PATCH 200/548] add zabbix-ntfy to intergration doc --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index dd9b897f..a5a22530 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -185,6 +185,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS) - [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS) - [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python) +- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services ## Blog + forum posts From 9e4a48b058b6ffde0d2ec9104d16b1a69af3c5df Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 19 Feb 2026 20:48:01 -0500 Subject: [PATCH 201/548] Make server tests also run against postgres --- message/store.go | 12 +- message/store_postgres.go | 6 +- message/store_sqlite.go | 6 +- server/server_account_test.go | 1192 ++++--- server/server_admin_test.go | 826 ++--- server/server_manager_test.go | 32 +- server/server_payments_test.go | 1254 +++---- server/server_test.go | 6035 +++++++++++++++++--------------- server/server_twilio_test.go | 510 +-- server/server_webpush_test.go | 332 +- 10 files changed, 5377 insertions(+), 4828 deletions(-) diff --git a/message/store.go b/message/store.go index c51f732f..05ab31d0 100644 --- a/message/store.go +++ b/message/store.go @@ -26,12 +26,13 @@ type Store interface { AddMessage(m *model.Message) error AddMessages(ms []*model.Message) error DB() *sql.DB + Message(id string) (*model.Message, error) + MessageCounts() (map[string]int, error) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) MessagesDue() ([]*model.Message, error) MessagesExpired() ([]string, error) - Message(id string) (*model.Message, error) MarkPublished(m *model.Message) error - MessageCounts() (map[string]int, error) + UpdateMessageTime(messageID string, timestamp int64) error Topics() ([]string, error) DeleteMessages(ids ...string) error DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) @@ -71,6 +72,7 @@ type storeQueries struct { selectAttachmentsSizeByUserID string selectStats string updateStats string + updateMessageTime string } // commonStore implements store operations that are identical across database backends @@ -302,6 +304,12 @@ func (c *commonStore) Message(id string) (*model.Message, error) { return readMessage(rows) } +// UpdateMessageTime updates the time column for a message by ID. This is only used for testing. +func (c *commonStore) UpdateMessageTime(messageID string, timestamp int64) error { + _, err := c.db.Exec(c.queries.updateMessageTime, timestamp, messageID) + return err +} + func (c *commonStore) MarkPublished(m *model.Message) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/message/store_postgres.go b/message/store_postgres.go index 3efab244..46c26bad 100644 --- a/message/store_postgres.go +++ b/message/store_postgres.go @@ -71,8 +71,9 @@ const ( pgSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2` pgSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2` - pgSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` - pgUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` + pgSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` + pgUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` + pgUpdateMessageTimesQuery = `UPDATE message SET time = $1 WHERE mid = $2` ) var pgQueries = storeQueries{ @@ -100,6 +101,7 @@ var pgQueries = storeQueries{ selectAttachmentsSizeByUserID: pgSelectAttachmentsSizeByUserIDQuery, selectStats: pgSelectStatsQuery, updateStats: pgUpdateStatsQuery, + updateMessageTime: pgUpdateMessageTimesQuery, } // NewPostgresStore creates a new PostgreSQL-backed message cache store. diff --git a/message/store_sqlite.go b/message/store_sqlite.go index 275fa43d..566682b1 100644 --- a/message/store_sqlite.go +++ b/message/store_sqlite.go @@ -74,8 +74,9 @@ const ( sqliteSelectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?` sqliteSelectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?` - sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` - sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` + sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` + sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` + sqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?` ) var sqliteQueries = storeQueries{ @@ -103,6 +104,7 @@ var sqliteQueries = storeQueries{ selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery, selectStats: sqliteSelectStatsQuery, updateStats: sqliteUpdateStatsQuery, + updateMessageTime: sqliteUpdateMessageTimeQuery, } // NewSQLiteStore creates a SQLite file-backed cache diff --git a/server/server_account_test.go b/server/server_account_test.go index 409ccfcf..d573e06e 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -15,706 +15,748 @@ import ( ) func TestAccount_Signup_Success(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() - rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 200, rr.Code) + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.NotEmpty(t, token.Token) + require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires) + require.True(t, strings.HasPrefix(token.Token, "tk_")) + require.Equal(t, "9.9.9.9", token.LastOrigin) + require.True(t, token.LastAccess > time.Now().Unix()-2) + require.True(t, token.LastAccess < time.Now().Unix()+2) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "phil", account.Username) + require.Equal(t, "user", account.Role) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("", token.Token), // We allow a fake basic auth to make curl-ing easier (curl -u :) + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "phil", account.Username) }) - require.Equal(t, 200, rr.Code) - token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) - require.NotEmpty(t, token.Token) - require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires) - require.True(t, strings.HasPrefix(token.Token, "tk_")) - require.Equal(t, "9.9.9.9", token.LastOrigin) - require.True(t, token.LastAccess > time.Now().Unix()-2) - require.True(t, token.LastAccess < time.Now().Unix()+2) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BearerAuth(token.Token), - }) - require.Equal(t, 200, rr.Code) - account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, "phil", account.Username) - require.Equal(t, "user", account.Role) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("", token.Token), // We allow a fake basic auth to make curl-ing easier (curl -u :) - }) - require.Equal(t, 200, rr.Code) - account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, "phil", account.Username) } func TestAccount_Signup_UserExists(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() - rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 200, rr.Code) + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 409, rr.Code) - require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code) + rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 409, rr.Code) + require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code) + }) } func TestAccount_Signup_LimitReached(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() - for i := 0; i < 3; i++ { - rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) - require.Equal(t, 200, rr.Code) - } - rr := request(t, s, "POST", "/v1/account", `{"username":"thiswontwork", "password":"mypass"}`, nil) - require.Equal(t, 429, rr.Code) - require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code) + for i := 0; i < 3; i++ { + rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) + require.Equal(t, 200, rr.Code) + } + rr := request(t, s, "POST", "/v1/account", `{"username":"thiswontwork", "password":"mypass"}`, nil) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code) + }) } func TestAccount_Signup_AsUser(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() - log.Info("1") - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - log.Info("2") - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - log.Info("3") - rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + log.Info("1") + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + log.Info("2") + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + log.Info("3") + rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + log.Info("4") + rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) }) - require.Equal(t, 200, rr.Code) - log.Info("4") - rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 401, rr.Code) } func TestAccount_Signup_Disabled(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = false - s := newTestServer(t, conf) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = false + s := newTestServer(t, conf) + defer s.closeDatabases() - rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 400, rr.Code) - require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code) + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code) + }) } func TestAccount_Signup_Rate_Limit(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) - for i := 0; i < 3; i++ { - rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) - require.Equal(t, 200, rr.Code, "failed on iteration %d", i) - } - rr := request(t, s, "POST", "/v1/account", `{"username":"notallowed", "password":"mypass"}`, nil) - require.Equal(t, 429, rr.Code) - require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code) + for i := 0; i < 3; i++ { + rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) + require.Equal(t, 200, rr.Code, "failed on iteration %d", i) + } + rr := request(t, s, "POST", "/v1/account", `{"username":"notallowed", "password":"mypass"}`, nil) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code) + }) } func TestAccount_Get_Anonymous(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.VisitorRequestLimitReplenish = 86 * time.Second - conf.VisitorEmailLimitReplenish = time.Hour - conf.VisitorAttachmentTotalSizeLimit = 5123 - conf.AttachmentFileSizeLimit = 512 - s := newTestServer(t, conf) - s.smtpSender = &testMailer{} - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.VisitorRequestLimitReplenish = 86 * time.Second + conf.VisitorEmailLimitReplenish = time.Hour + conf.VisitorAttachmentTotalSizeLimit = 5123 + conf.AttachmentFileSizeLimit = 512 + s := newTestServer(t, conf) + s.smtpSender = &testMailer{} + defer s.closeDatabases() - rr := request(t, s, "GET", "/v1/account", "", nil) - require.Equal(t, 200, rr.Code) - account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, "*", account.Username) - require.Equal(t, string(user.RoleAnonymous), account.Role) - require.Equal(t, "ip", account.Limits.Basis) - require.Equal(t, int64(1004), account.Limits.Messages) // I hate this - require.Equal(t, int64(24), account.Limits.Emails) // I hate this - require.Equal(t, int64(5123), account.Limits.AttachmentTotalSize) - require.Equal(t, int64(512), account.Limits.AttachmentFileSize) - require.Equal(t, int64(0), account.Stats.Messages) - require.Equal(t, int64(1004), account.Stats.MessagesRemaining) - require.Equal(t, int64(0), account.Stats.Emails) - require.Equal(t, int64(24), account.Stats.EmailsRemaining) - require.Equal(t, int64(0), account.Stats.Calls) - require.Equal(t, int64(0), account.Stats.CallsRemaining) + rr := request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "*", account.Username) + require.Equal(t, string(user.RoleAnonymous), account.Role) + require.Equal(t, "ip", account.Limits.Basis) + require.Equal(t, int64(1004), account.Limits.Messages) // I hate this + require.Equal(t, int64(24), account.Limits.Emails) // I hate this + require.Equal(t, int64(5123), account.Limits.AttachmentTotalSize) + require.Equal(t, int64(512), account.Limits.AttachmentFileSize) + require.Equal(t, int64(0), account.Stats.Messages) + require.Equal(t, int64(1004), account.Stats.MessagesRemaining) + require.Equal(t, int64(0), account.Stats.Emails) + require.Equal(t, int64(24), account.Stats.EmailsRemaining) + require.Equal(t, int64(0), account.Stats.Calls) + require.Equal(t, int64(0), account.Stats.CallsRemaining) - rr = request(t, s, "POST", "/mytopic", "", nil) - require.Equal(t, 200, rr.Code) - rr = request(t, s, "POST", "/mytopic", "", map[string]string{ - "Email": "phil@ntfy.sh", + rr = request(t, s, "POST", "/mytopic", "", nil) + require.Equal(t, 200, rr.Code) + rr = request(t, s, "POST", "/mytopic", "", map[string]string{ + "Email": "phil@ntfy.sh", + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(2), account.Stats.Messages) + require.Equal(t, int64(1002), account.Stats.MessagesRemaining) + require.Equal(t, int64(1), account.Stats.Emails) + require.Equal(t, int64(23), account.Stats.EmailsRemaining) }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "GET", "/v1/account", "", nil) - require.Equal(t, 200, rr.Code) - account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, int64(2), account.Stats.Messages) - require.Equal(t, int64(1002), account.Stats.MessagesRemaining) - require.Equal(t, int64(1), account.Stats.Emails) - require.Equal(t, int64(23), account.Stats.EmailsRemaining) } func TestAccount_ChangeSettings(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - u, _ := s.userManager.User("phil") - token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + u, _ := s.userManager.User("phil") + token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false) - rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"delete_after": 86400}, "language": "de"}`, map[string]string{ + "Authorization": util.BearerAuth(token.Value), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{ + "Authorization": util.BearerAuth(token.Value), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "de", account.Language) + require.Equal(t, util.Int(86400), account.Notification.DeleteAfter) + require.Equal(t, util.String("juntos"), account.Notification.Sound) + require.Nil(t, account.Notification.MinPriority) // Not set }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"delete_after": 86400}, "language": "de"}`, map[string]string{ - "Authorization": util.BearerAuth(token.Value), - }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "GET", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{ - "Authorization": util.BearerAuth(token.Value), - }) - require.Equal(t, 200, rr.Code) - account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, "de", account.Language) - require.Equal(t, util.Int(86400), account.Notification.DeleteAfter) - require.Equal(t, util.String("juntos"), account.Notification.Sound) - require.Nil(t, account.Notification.MinPriority) // Not set } func TestAccount_Subscription_AddUpdateDelete(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, 1, len(account.Subscriptions)) + require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL) + require.Equal(t, "def", account.Subscriptions[0].Topic) + require.Nil(t, account.Subscriptions[0].DisplayName) + + rr = request(t, s, "PATCH", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def", "display_name": "ding dong"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, 1, len(account.Subscriptions)) + require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL) + require.Equal(t, "def", account.Subscriptions[0].Topic) + require.Equal(t, util.String("ding dong"), account.Subscriptions[0].DisplayName) + + rr = request(t, s, "DELETE", "/v1/account/subscription", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "X-BaseURL": "http://abc.com", + "X-Topic": "def", + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, 0, len(account.Subscriptions)) }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, 1, len(account.Subscriptions)) - require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL) - require.Equal(t, "def", account.Subscriptions[0].Topic) - require.Nil(t, account.Subscriptions[0].DisplayName) - - rr = request(t, s, "PATCH", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def", "display_name": "ding dong"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, 1, len(account.Subscriptions)) - require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL) - require.Equal(t, "def", account.Subscriptions[0].Topic) - require.Equal(t, util.String("ding dong"), account.Subscriptions[0].DisplayName) - - rr = request(t, s, "DELETE", "/v1/account/subscription", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "X-BaseURL": "http://abc.com", - "X-Topic": "def", - }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, 0, len(account.Subscriptions)) } func TestAccount_ChangePassword(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.AuthUsers = []*user.User{ - {Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass - } - s := newTestServer(t, conf) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.AuthUsers = []*user.User{ + {Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass + } + s := newTestServer(t, conf) + defer s.closeDatabases() - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - rr := request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": ""}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + rr := request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": ""}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 400, rr.Code) + + rr = request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": "new password"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code) + + rr = request(t, s, "POST", "/v1/account/password", `{"password": "phil", "new_password": "new password"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 401, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "new password"), + }) + require.Equal(t, 200, rr.Code) + + // Cannot change password of provisioned user + rr = request(t, s, "POST", "/v1/account/password", `{"password": "philpass", "new_password": "new password"}`, map[string]string{ + "Authorization": util.BasicAuth("philuser", "philpass"), + }) + require.Equal(t, 409, rr.Code) }) - require.Equal(t, 400, rr.Code) - - rr = request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": "new password"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 400, rr.Code) - require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code) - - rr = request(t, s, "POST", "/v1/account/password", `{"password": "phil", "new_password": "new password"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 401, rr.Code) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "new password"), - }) - require.Equal(t, 200, rr.Code) - - // Cannot change password of provisioned user - rr = request(t, s, "POST", "/v1/account/password", `{"password": "philpass", "new_password": "new password"}`, map[string]string{ - "Authorization": util.BasicAuth("philuser", "philpass"), - }) - require.Equal(t, 409, rr.Code) } func TestAccount_ChangePassword_NoAccount(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() - rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil) - require.Equal(t, 401, rr.Code) + rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil) + require.Equal(t, 401, rr.Code) + }) } func TestAccount_ExtendToken(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + + time.Sleep(time.Second) + + rr = request(t, s, "PATCH", "/v1/account/token", "", map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + extendedToken, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, token.Token, extendedToken.Token) + require.True(t, token.Expires < extendedToken.Expires) + + expires := time.Now().Add(999 * time.Hour) + body := fmt.Sprintf(`{"token":"%s", "label":"some label", "expires": %d}`, token.Token, expires.Unix()) + rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, "some label", token.Label) + require.Equal(t, expires.Unix(), token.Expires) }) - require.Equal(t, 200, rr.Code) - token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) - require.Nil(t, err) - - time.Sleep(time.Second) - - rr = request(t, s, "PATCH", "/v1/account/token", "", map[string]string{ - "Authorization": util.BearerAuth(token.Token), - }) - require.Equal(t, 200, rr.Code) - extendedToken, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) - require.Nil(t, err) - require.Equal(t, token.Token, extendedToken.Token) - require.True(t, token.Expires < extendedToken.Expires) - - expires := time.Now().Add(999 * time.Hour) - body := fmt.Sprintf(`{"token":"%s", "label":"some label", "expires": %d}`, token.Token, expires.Unix()) - rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{ - "Authorization": util.BearerAuth(token.Token), - }) - require.Equal(t, 200, rr.Code) - token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) - require.Nil(t, err) - require.Equal(t, "some label", token.Label) - require.Equal(t, expires.Unix(), token.Expires) } func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), // Not Bearer! + rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), // Not Bearer! + }) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code) }) - require.Equal(t, 400, rr.Code) - require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code) } func TestAccount_DeleteToken(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix()) + + // Delete token failure (using basic auth) + rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), // Not Bearer! + }) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code) + + // Delete token with wrong token + rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ + "Authorization": util.BearerAuth("invalidtoken"), + }) + require.Equal(t, 401, rr.Code) + + // Delete token with correct token + rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + + // Cannot get account anymore + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 401, rr.Code) }) - require.Equal(t, 200, rr.Code) - token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) - require.Nil(t, err) - require.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix()) - - // Delete token failure (using basic auth) - rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), // Not Bearer! - }) - require.Equal(t, 400, rr.Code) - require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code) - - // Delete token with wrong token - rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ - "Authorization": util.BearerAuth("invalidtoken"), - }) - require.Equal(t, 401, rr.Code) - - // Delete token with correct token - rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ - "Authorization": util.BearerAuth(token.Token), - }) - require.Equal(t, 200, rr.Code) - - // Cannot get account anymore - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BearerAuth(token.Token), - }) - require.Equal(t, 401, rr.Code) } func TestAccount_Delete_Success(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) - rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 200, rr.Code) + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Account was marked deleted + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 401, rr.Code) + + // Cannot re-create account, since still exists + rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 409, rr.Code) }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - - // Account was marked deleted - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 401, rr.Code) - - // Cannot re-create account, since still exists - rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 409, rr.Code) } func TestAccount_Delete_Not_Allowed(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) - rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 200, rr.Code) + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "DELETE", "/v1/account", "", nil) - require.Equal(t, 401, rr.Code) + rr = request(t, s, "DELETE", "/v1/account", "", nil) + require.Equal(t, 401, rr.Code) - rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, nil) - require.Equal(t, 401, rr.Code) + rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, nil) + require.Equal(t, 401, rr.Code) - rr = request(t, s, "DELETE", "/v1/account", `{"password":"INCORRECT"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), + rr = request(t, s, "DELETE", "/v1/account", `{"password":"INCORRECT"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code) }) - require.Equal(t, 400, rr.Code) - require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code) } func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) - rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 200, rr.Code) + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic", "everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 401, rr.Code) }) - require.Equal(t, 401, rr.Code) } func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + s := newTestServer(t, conf) - // A user, an admin, and a reservation walk into a bar - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - ReservationLimit: 2, - })) - require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro")) - require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll)) + // A user, an admin, and a reservation walk into a bar + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + ReservationLimit: 2, + })) + require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro")) + require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll)) - require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro")) + require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro")) - require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin, false)) - // Admin can reserve topic - rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "adminpass"), + // Admin can reserve topic + rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "adminpass"), + }) + require.Equal(t, 200, rr.Code) + + // User cannot reserve already reserved topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("noadmin2", "pass"), + }) + require.Equal(t, 409, rr.Code) + + // Admin cannot reserve already reserved topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "adminpass"), + }) + require.Equal(t, 409, rr.Code) + + reservations, err := s.userManager.Reservations("phil") + require.Nil(t, err) + require.Equal(t, 1, len(reservations)) + require.Equal(t, "sometopic", reservations[0].Topic) + + reservations, err = s.userManager.Reservations("noadmin1") + require.Nil(t, err) + require.Equal(t, 1, len(reservations)) + require.Equal(t, "mytopic", reservations[0].Topic) + + reservations, err = s.userManager.Reservations("noadmin2") + require.Nil(t, err) + require.Equal(t, 0, len(reservations)) }) - require.Equal(t, 200, rr.Code) - - // User cannot reserve already reserved topic - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("noadmin2", "pass"), - }) - require.Equal(t, 409, rr.Code) - - // Admin cannot reserve already reserved topic - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "adminpass"), - }) - require.Equal(t, 409, rr.Code) - - reservations, err := s.userManager.Reservations("phil") - require.Nil(t, err) - require.Equal(t, 1, len(reservations)) - require.Equal(t, "sometopic", reservations[0].Topic) - - reservations, err = s.userManager.Reservations("noadmin1") - require.Nil(t, err) - require.Equal(t, 1, len(reservations)) - require.Equal(t, "mytopic", reservations[0].Topic) - - reservations, err = s.userManager.Reservations("noadmin2") - require.Nil(t, err) - require.Equal(t, 0, len(reservations)) } func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.EnableSignup = true - conf.EnableReservations = true - conf.TwilioAccount = "dummy" - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.EnableSignup = true + conf.EnableReservations = true + conf.TwilioAccount = "dummy" + s := newTestServer(t, conf) - // Create user - rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 200, rr.Code) + // Create user + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) - // Create a tier - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 123, - MessageExpiryDuration: 86400 * time.Second, - EmailLimit: 32, - CallLimit: 10, - ReservationLimit: 2, - AttachmentFileSizeLimit: 1231231, - AttachmentTotalSizeLimit: 123123, - AttachmentExpiryDuration: 10800 * time.Second, - AttachmentBandwidthLimit: 21474836480, - })) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + // Create a tier + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 123, + MessageExpiryDuration: 86400 * time.Second, + EmailLimit: 32, + CallLimit: 10, + ReservationLimit: 2, + AttachmentFileSizeLimit: 1231231, + AttachmentTotalSizeLimit: 123123, + AttachmentExpiryDuration: 10800 * time.Second, + AttachmentBandwidthLimit: 21474836480, + })) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - // Reserve two topics - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), + // Reserve two topics + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "another", "everyone":"read-only"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Trying to reserve a third should fail + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "yet-another", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 429, rr.Code) + + // Modify existing should still work + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "another", "everyone":"write-only"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Check account result + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "pro", account.Tier.Code) + require.Equal(t, int64(123), account.Limits.Messages) + require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration) + require.Equal(t, int64(32), account.Limits.Emails) + require.Equal(t, int64(10), account.Limits.Calls) + require.Equal(t, int64(2), account.Limits.Reservations) + require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize) + require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) + require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration) + require.Equal(t, int64(21474836480), account.Limits.AttachmentBandwidth) + require.Equal(t, 2, len(account.Reservations)) + require.Equal(t, "another", account.Reservations[0].Topic) + require.Equal(t, "write-only", account.Reservations[0].Everyone) + require.Equal(t, "mytopic", account.Reservations[1].Topic) + require.Equal(t, "deny-all", account.Reservations[1].Everyone) + + // Delete and re-check + rr = request(t, s, "DELETE", "/v1/account/reservation/another", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, 1, len(account.Reservations)) + require.Equal(t, "mytopic", account.Reservations[0].Topic) }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "another", "everyone":"read-only"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - - // Trying to reserve a third should fail - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "yet-another", "everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 429, rr.Code) - - // Modify existing should still work - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "another", "everyone":"write-only"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - - // Check account result - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, "pro", account.Tier.Code) - require.Equal(t, int64(123), account.Limits.Messages) - require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration) - require.Equal(t, int64(32), account.Limits.Emails) - require.Equal(t, int64(10), account.Limits.Calls) - require.Equal(t, int64(2), account.Limits.Reservations) - require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize) - require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) - require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration) - require.Equal(t, int64(21474836480), account.Limits.AttachmentBandwidth) - require.Equal(t, 2, len(account.Reservations)) - require.Equal(t, "another", account.Reservations[0].Topic) - require.Equal(t, "write-only", account.Reservations[0].Everyone) - require.Equal(t, "mytopic", account.Reservations[1].Topic) - require.Equal(t, "deny-all", account.Reservations[1].Everyone) - - // Delete and re-check - rr = request(t, s, "DELETE", "/v1/account/reservation/another", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, 1, len(account.Reservations)) - require.Equal(t, "mytopic", account.Reservations[0].Topic) } func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - conf.AuthDefault = user.PermissionReadWrite - conf.EnableSignup = true - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.AuthDefault = user.PermissionReadWrite + conf.EnableSignup = true + s := newTestServer(t, conf) - // Create user with tier - rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) - require.Equal(t, 200, rr.Code) + // Create user with tier + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 20, - ReservationLimit: 2, - })) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 20, + ReservationLimit: 2, + })) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - // Reserve a topic - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), + // Reserve a topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Publish a message + rr = request(t, s, "POST", "/mytopic", `Howdy`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Publish a message (as anonymous) + rr = request(t, s, "POST", "/mytopic", `Howdy`, nil) + require.Equal(t, 403, rr.Code) }) - require.Equal(t, 200, rr.Code) - - // Publish a message - rr = request(t, s, "POST", "/mytopic", `Howdy`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - - // Publish a message (as anonymous) - rr = request(t, s, "POST", "/mytopic", `Howdy`, nil) - require.Equal(t, 403, rr.Code) } func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { - t.Parallel() - conf := newTestConfigWithAuthFile(t) - conf.AuthDefault = user.PermissionReadWrite - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + conf := newTestConfigWithAuthFile(t) + conf.AuthDefault = user.PermissionReadWrite + s := newTestServer(t, conf) - // Create user with tier - require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false)) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 20, - MessageExpiryDuration: time.Hour, - ReservationLimit: 2, - AttachmentTotalSizeLimit: 10000, - AttachmentFileSizeLimit: 10000, - AttachmentExpiryDuration: time.Hour, - AttachmentBandwidthLimit: 10000, - })) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + // Create user with tier + require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false)) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 20, + MessageExpiryDuration: time.Hour, + ReservationLimit: 2, + AttachmentTotalSizeLimit: 10000, + AttachmentFileSizeLimit: 10000, + AttachmentExpiryDuration: time.Hour, + AttachmentBandwidthLimit: 10000, + })) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - // Reserve two topics "mytopic1" and "mytopic2" - rr := request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic1", "everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) + // Reserve two topics "mytopic1" and "mytopic2" + rr := request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic1", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic2", "everyone":"deny-all"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic2", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) - // Publish a message with attachment to each topic - rr = request(t, s, "POST", "/mytopic1?f=attach.txt", `Howdy`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - m1 := toMessage(t, rr.Body.String()) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + // Publish a message with attachment to each topic + rr = request(t, s, "POST", "/mytopic1?f=attach.txt", `Howdy`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + m1 := toMessage(t, rr.Body.String()) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) - rr = request(t, s, "POST", "/mytopic2?f=attach.txt", `Howdy`, map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - m2 := toMessage(t, rr.Body.String()) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) + rr = request(t, s, "POST", "/mytopic2?f=attach.txt", `Howdy`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + m2 := toMessage(t, rr.Body.String()) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) - // Pre-verify message count and file - ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(ms)) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) - - ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(ms)) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) - - // Delete reservation - rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{ - "X-Delete-Messages": "true", - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic2", ``, map[string]string{ - "X-Delete-Messages": "false", - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 200, rr.Code) - - // Verify that messages and attachments were deleted - // This does not explicitly call the manager! - waitFor(t, func() bool { + // Pre-verify message count and file ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) require.Nil(t, err) - return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + require.Equal(t, 1, len(ms)) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + + ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(ms)) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) + + // Delete reservation + rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{ + "X-Delete-Messages": "true", + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic2", ``, map[string]string{ + "X-Delete-Messages": "false", + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Verify that messages and attachments were deleted + // This does not explicitly call the manager! + waitFor(t, func() bool { + ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) + require.Nil(t, err) + return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + }) + + ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 0, len(ms)) + require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) + + ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(ms)) + require.Equal(t, m2.ID, ms[0].ID) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) }) - - ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 0, len(ms)) - require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) - - ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(ms)) - require.Equal(t, m2.ID, ms[0].ID) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) } /*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) { diff --git a/server/server_admin_test.go b/server/server_admin_test.go index cedaed87..131cd145 100644 --- a/server/server_admin_test.go +++ b/server/server_admin_test.go @@ -11,427 +11,451 @@ import ( ) func TestVersion_Admin(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.BuildVersion = "1.2.3" - c.BuildCommit = "abcdef0" - c.BuildDate = "2026-02-08T00:00:00Z" - s := newTestServer(t, c) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BuildVersion = "1.2.3" + c.BuildCommit = "abcdef0" + c.BuildDate = "2026-02-08T00:00:00Z" + s := newTestServer(t, c) + defer s.closeDatabases() - // Create admin and regular user - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + // Create admin and regular user + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - // Admin can access /v1/version - rr := request(t, s, "GET", "/v1/version", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Admin can access /v1/version + rr := request(t, s, "GET", "/v1/version", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + var versionResponse apiVersionResponse + require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse)) + require.Equal(t, "1.2.3", versionResponse.Version) + require.Equal(t, "abcdef0", versionResponse.Commit) + require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date) + + // Non-admin user cannot access /v1/version + rr = request(t, s, "GET", "/v1/version", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) + + // Unauthenticated user cannot access /v1/version + rr = request(t, s, "GET", "/v1/version", "", nil) + require.Equal(t, 401, rr.Code) }) - require.Equal(t, 200, rr.Code) - - var versionResponse apiVersionResponse - require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse)) - require.Equal(t, "1.2.3", versionResponse.Version) - require.Equal(t, "abcdef0", versionResponse.Commit) - require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date) - - // Non-admin user cannot access /v1/version - rr = request(t, s, "GET", "/v1/version", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 401, rr.Code) - - // Unauthenticated user cannot access /v1/version - rr = request(t, s, "GET", "/v1/version", "", nil) - require.Equal(t, 401, rr.Code) } func TestUser_AddRemove(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() - // Create admin, tier - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "tier1", - })) + // Create admin, tier + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "tier1", + })) - // Create user via API - rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Create user via API + rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Create user with tier via API + rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Check users + users, err := s.userManager.Users() + require.Nil(t, err) + require.Equal(t, 4, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "ben", users[1].Name) + require.Equal(t, user.RoleUser, users[1].Role) + require.Nil(t, users[1].Tier) + require.Equal(t, "emma", users[2].Name) + require.Equal(t, user.RoleUser, users[2].Role) + require.Equal(t, "tier1", users[2].Tier.Code) + require.Equal(t, user.Everyone, users[3].Name) + + // Delete user via API + rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Check user was deleted + users, err = s.userManager.Users() + require.Nil(t, err) + require.Equal(t, 3, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "emma", users[1].Name) + require.Equal(t, user.Everyone, users[2].Name) + + // Reject invalid user change + rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 400, rr.Code) }) - require.Equal(t, 200, rr.Code) - - // Create user with tier via API - rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Check users - users, err := s.userManager.Users() - require.Nil(t, err) - require.Equal(t, 4, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "ben", users[1].Name) - require.Equal(t, user.RoleUser, users[1].Role) - require.Nil(t, users[1].Tier) - require.Equal(t, "emma", users[2].Name) - require.Equal(t, user.RoleUser, users[2].Role) - require.Equal(t, "tier1", users[2].Tier.Code) - require.Equal(t, user.Everyone, users[3].Name) - - // Delete user via API - rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Check user was deleted - users, err = s.userManager.Users() - require.Nil(t, err) - require.Equal(t, 3, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "emma", users[1].Name) - require.Equal(t, user.Everyone, users[2].Name) - - // Reject invalid user change - rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 400, rr.Code) } func TestUser_AddWithPasswordHash(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() - // Create admin - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + // Create admin + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - // Create user via API - rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) + // Create user via API + rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) - // Check that user can login with password - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 200, rr.Code) - - // Check users - users, err := s.userManager.Users() - require.Nil(t, err) - require.Equal(t, 3, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, user.RoleAdmin, users[0].Role) - require.Equal(t, "ben", users[1].Name) - require.Equal(t, user.RoleUser, users[1].Role) -} - -func TestUser_ChangeUserPassword(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() - - // Create admin - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - - // Create user via API - rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Try to login with first password - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 200, rr.Code) - - // Change password via API - rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Make sure first password fails - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 401, rr.Code) - - // Try to login with second password - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben-two"), - }) - require.Equal(t, 200, rr.Code) -} - -func TestUser_ChangeUserTier(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() - - // Create admin, tier - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "tier1", - })) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "tier2", - })) - - // Create user with tier via API - rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Check users - users, err := s.userManager.Users() - require.Nil(t, err) - require.Equal(t, 3, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "ben", users[1].Name) - require.Equal(t, user.RoleUser, users[1].Role) - require.Equal(t, "tier1", users[1].Tier.Code) - - // Change user tier via API - rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Check users again - users, err = s.userManager.Users() - require.Nil(t, err) - require.Equal(t, "tier2", users[1].Tier.Code) -} - -func TestUser_ChangeUserPasswordAndTier(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() - - // Create admin, tier - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "tier1", - })) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "tier2", - })) - - // Create user with tier via API - rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Check users - users, err := s.userManager.Users() - require.Nil(t, err) - require.Equal(t, 3, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "ben", users[1].Name) - require.Equal(t, user.RoleUser, users[1].Role) - require.Equal(t, "tier1", users[1].Tier.Code) - - // Change user password and tier via API - rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Make sure first password fails - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 401, rr.Code) - - // Try to login with second password - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben-two"), - }) - require.Equal(t, 200, rr.Code) - - // Check new tier - users, err = s.userManager.Users() - require.Nil(t, err) - require.Equal(t, "tier2", users[1].Tier.Code) -} - -func TestUser_ChangeUserPasswordWithHash(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() - - // Create admin - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - - // Create user with tier via API - rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Try to login with first password - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "not-ben"), - }) - require.Equal(t, 200, rr.Code) - - // Change user password and tier via API - rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Try to login with second password - rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 200, rr.Code) -} - -func TestUser_DontChangeAdminPassword(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() - - // Create admin - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false)) - - // Try to change password via API - rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 403, rr.Code) -} - -func TestUser_AddRemove_Failures(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() - - // Create admin - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - - // Cannot create user with invalid username - rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 400, rr.Code) - - // Cannot create user if user already exists - rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code) - - // Cannot create user with invalid tier - rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code) - - // Cannot delete user as non-admin - rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 401, rr.Code) - - // Delete user via API - rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) -} - -func TestAccess_AllowReset(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) - defer s.closeDatabases() - - // User and admin - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - - // Subscribing not allowed - rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 403, rr.Code) - - // Grant access - rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Now subscribing is allowed - rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 200, rr.Code) - - // Reset access - rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Subscribing not allowed (again) - rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 403, rr.Code) -} - -func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) - defer s.closeDatabases() - - // User - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - - // Grant access fails, because non-admin - rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 401, rr.Code) -} - -func TestAccess_AllowReset_KillConnection(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) - defer s.closeDatabases() - - // User and admin, grant access to "gol*" topics - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard! - - start, timeTaken := time.Now(), atomic.Int64{} - go func() { - rr := request(t, s, "GET", "/gold/json", "", map[string]string{ + // Check that user can login with password + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ "Authorization": util.BasicAuth("ben", "ben"), }) require.Equal(t, 200, rr.Code) - timeTaken.Store(time.Since(start).Milliseconds()) - }() - time.Sleep(500 * time.Millisecond) - // Reset access - rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Check users + users, err := s.userManager.Users() + require.Nil(t, err) + require.Equal(t, 3, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, user.RoleAdmin, users[0].Role) + require.Equal(t, "ben", users[1].Name) + require.Equal(t, user.RoleUser, users[1].Role) }) - require.Equal(t, 200, rr.Code) +} - // Wait for connection to be killed; this will fail if the connection is never killed - waitFor(t, func() bool { - return timeTaken.Load() >= 500 +func TestUser_ChangeUserPassword(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + // Create admin + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + + // Create user via API + rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Try to login with first password + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, rr.Code) + + // Change password via API + rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Make sure first password fails + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) + + // Try to login with second password + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben-two"), + }) + require.Equal(t, 200, rr.Code) + }) +} + +func TestUser_ChangeUserTier(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + // Create admin, tier + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "tier1", + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "tier2", + })) + + // Create user with tier via API + rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Check users + users, err := s.userManager.Users() + require.Nil(t, err) + require.Equal(t, 3, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "ben", users[1].Name) + require.Equal(t, user.RoleUser, users[1].Role) + require.Equal(t, "tier1", users[1].Tier.Code) + + // Change user tier via API + rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Check users again + users, err = s.userManager.Users() + require.Nil(t, err) + require.Equal(t, "tier2", users[1].Tier.Code) + }) +} + +func TestUser_ChangeUserPasswordAndTier(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + // Create admin, tier + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "tier1", + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "tier2", + })) + + // Create user with tier via API + rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Check users + users, err := s.userManager.Users() + require.Nil(t, err) + require.Equal(t, 3, len(users)) + require.Equal(t, "phil", users[0].Name) + require.Equal(t, "ben", users[1].Name) + require.Equal(t, user.RoleUser, users[1].Role) + require.Equal(t, "tier1", users[1].Tier.Code) + + // Change user password and tier via API + rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Make sure first password fails + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) + + // Try to login with second password + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben-two"), + }) + require.Equal(t, 200, rr.Code) + + // Check new tier + users, err = s.userManager.Users() + require.Nil(t, err) + require.Equal(t, "tier2", users[1].Tier.Code) + }) +} + +func TestUser_ChangeUserPasswordWithHash(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + // Create admin + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + + // Create user with tier via API + rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Try to login with first password + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "not-ben"), + }) + require.Equal(t, 200, rr.Code) + + // Change user password and tier via API + rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Try to login with second password + rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, rr.Code) + }) +} + +func TestUser_DontChangeAdminPassword(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + // Create admin + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false)) + + // Try to change password via API + rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 403, rr.Code) + }) +} + +func TestUser_AddRemove_Failures(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) + defer s.closeDatabases() + + // Create admin + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + + // Cannot create user with invalid username + rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 400, rr.Code) + + // Cannot create user if user already exists + rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code) + + // Cannot create user with invalid tier + rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code) + + // Cannot delete user as non-admin + rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) + + // Delete user via API + rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + }) +} + +func TestAccess_AllowReset(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) + defer s.closeDatabases() + + // User and admin + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + + // Subscribing not allowed + rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 403, rr.Code) + + // Grant access + rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Now subscribing is allowed + rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, rr.Code) + + // Reset access + rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Subscribing not allowed (again) + rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 403, rr.Code) + }) +} + +func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) + defer s.closeDatabases() + + // User + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + + // Grant access fails, because non-admin + rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) + }) +} + +func TestAccess_AllowReset_KillConnection(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) + defer s.closeDatabases() + + // User and admin, grant access to "gol*" topics + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard! + + start, timeTaken := time.Now(), atomic.Int64{} + go func() { + rr := request(t, s, "GET", "/gold/json", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, rr.Code) + timeTaken.Store(time.Since(start).Milliseconds()) + }() + time.Sleep(500 * time.Millisecond) + + // Reset access + rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // Wait for connection to be killed; this will fail if the connection is never killed + waitFor(t, func() bool { + return timeTaken.Load() >= 500 + }) }) } diff --git a/server/server_manager_test.go b/server/server_manager_test.go index f17d583f..1760737b 100644 --- a/server/server_manager_test.go +++ b/server/server_manager_test.go @@ -6,23 +6,25 @@ import ( ) func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) { - // Tests that the manager runs without attachment-cache-dir set, see #617 - c := newTestConfig(t) - c.AttachmentCacheDir = "" - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + // Tests that the manager runs without attachment-cache-dir set, see #617 + c := newTestConfig(t) + c.AttachmentCacheDir = "" + s := newTestServer(t, c) - // Publish a message - rr := request(t, s, "POST", "/mytopic", "hi", nil) - require.Equal(t, 200, rr.Code) - m := toMessage(t, rr.Body.String()) + // Publish a message + rr := request(t, s, "POST", "/mytopic", "hi", nil) + require.Equal(t, 200, rr.Code) + m := toMessage(t, rr.Body.String()) - // Expire message - require.Nil(t, s.messageCache.ExpireMessages("mytopic")) + // Expire message + require.Nil(t, s.messageCache.ExpireMessages("mytopic")) - // Does not panic - s.pruneMessages() + // Does not panic + s.pruneMessages() - // Actually deleted - _, err := s.messageCache.Message(m.ID) - require.Equal(t, errMessageNotFound, err) + // Actually deleted + _, err := s.messageCache.Message(m.ID) + require.Equal(t, errMessageNotFound, err) + }) } diff --git a/server/server_payments_test.go b/server/server_payments_test.go index 5dd75921..10c2a9b7 100644 --- a/server/server_payments_test.go +++ b/server/server_payments_test.go @@ -21,725 +21,745 @@ import ( ) func TestPayments_Tiers(t *testing.T) { - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + forEachBackend(t, func(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - c.VisitorRequestLimitReplenish = 12 * time.Hour - c.CacheDuration = 13 * time.Hour - c.AttachmentFileSizeLimit = 111 - c.VisitorAttachmentTotalSizeLimit = 222 - c.AttachmentExpiryDuration = 123 * time.Second - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + c.VisitorRequestLimitReplenish = 12 * time.Hour + c.CacheDuration = 13 * time.Hour + c.AttachmentFileSizeLimit = 111 + c.VisitorAttachmentTotalSizeLimit = 222 + c.AttachmentExpiryDuration = 123 * time.Second + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("ListPrices", mock.Anything). - Return([]*stripe.Price{ - {ID: "price_123", UnitAmount: 500}, - {ID: "price_124", UnitAmount: 5000}, - {ID: "price_456", UnitAmount: 1000}, - {ID: "price_457", UnitAmount: 10000}, - {ID: "price_999", UnitAmount: 9999}, - }, nil) + // Define how the mock should react + stripeMock. + On("ListPrices", mock.Anything). + Return([]*stripe.Price{ + {ID: "price_123", UnitAmount: 500}, + {ID: "price_124", UnitAmount: 5000}, + {ID: "price_456", UnitAmount: 1000}, + {ID: "price_457", UnitAmount: 10000}, + {ID: "price_999", UnitAmount: 9999}, + }, nil) - // Create tiers - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_1", - Code: "admin", - Name: "Admin", - })) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - Name: "Pro", - MessageLimit: 1000, - MessageExpiryDuration: time.Hour, - EmailLimit: 123, - ReservationLimit: 777, - AttachmentFileSizeLimit: 999, - AttachmentTotalSizeLimit: 888, - AttachmentExpiryDuration: time.Minute, - StripeMonthlyPriceID: "price_123", - StripeYearlyPriceID: "price_124", - })) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_444", - Code: "business", - Name: "Business", - MessageLimit: 2000, - MessageExpiryDuration: 10 * time.Hour, - EmailLimit: 123123, - ReservationLimit: 777333, - AttachmentFileSizeLimit: 999111, - AttachmentTotalSizeLimit: 888111, - AttachmentExpiryDuration: time.Hour, - StripeMonthlyPriceID: "price_456", - StripeYearlyPriceID: "price_457", - })) - response := request(t, s, "GET", "/v1/tiers", "", nil) - require.Equal(t, 200, response.Code) - var tiers []apiAccountBillingTier - require.Nil(t, json.NewDecoder(response.Body).Decode(&tiers)) - require.Equal(t, 3, len(tiers)) + // Create tiers + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_1", + Code: "admin", + Name: "Admin", + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + Name: "Pro", + MessageLimit: 1000, + MessageExpiryDuration: time.Hour, + EmailLimit: 123, + ReservationLimit: 777, + AttachmentFileSizeLimit: 999, + AttachmentTotalSizeLimit: 888, + AttachmentExpiryDuration: time.Minute, + StripeMonthlyPriceID: "price_123", + StripeYearlyPriceID: "price_124", + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_444", + Code: "business", + Name: "Business", + MessageLimit: 2000, + MessageExpiryDuration: 10 * time.Hour, + EmailLimit: 123123, + ReservationLimit: 777333, + AttachmentFileSizeLimit: 999111, + AttachmentTotalSizeLimit: 888111, + AttachmentExpiryDuration: time.Hour, + StripeMonthlyPriceID: "price_456", + StripeYearlyPriceID: "price_457", + })) + response := request(t, s, "GET", "/v1/tiers", "", nil) + require.Equal(t, 200, response.Code) + var tiers []apiAccountBillingTier + require.Nil(t, json.NewDecoder(response.Body).Decode(&tiers)) + require.Equal(t, 3, len(tiers)) - // Free tier - tier := tiers[0] - require.Equal(t, "", tier.Code) - require.Equal(t, "", tier.Name) - require.Equal(t, "ip", tier.Limits.Basis) - require.Equal(t, int64(0), tier.Limits.Reservations) - require.Equal(t, int64(2), tier.Limits.Messages) // :-( - require.Equal(t, int64(13*3600), tier.Limits.MessagesExpiryDuration) - require.Equal(t, int64(24), tier.Limits.Emails) - require.Equal(t, int64(111), tier.Limits.AttachmentFileSize) - require.Equal(t, int64(222), tier.Limits.AttachmentTotalSize) - require.Equal(t, int64(123), tier.Limits.AttachmentExpiryDuration) + // Free tier + tier := tiers[0] + require.Equal(t, "", tier.Code) + require.Equal(t, "", tier.Name) + require.Equal(t, "ip", tier.Limits.Basis) + require.Equal(t, int64(0), tier.Limits.Reservations) + require.Equal(t, int64(2), tier.Limits.Messages) // :-( + require.Equal(t, int64(13*3600), tier.Limits.MessagesExpiryDuration) + require.Equal(t, int64(24), tier.Limits.Emails) + require.Equal(t, int64(111), tier.Limits.AttachmentFileSize) + require.Equal(t, int64(222), tier.Limits.AttachmentTotalSize) + require.Equal(t, int64(123), tier.Limits.AttachmentExpiryDuration) - // Admin tier is not included, because it is not paid! + // Admin tier is not included, because it is not paid! - tier = tiers[1] - require.Equal(t, "pro", tier.Code) - require.Equal(t, "Pro", tier.Name) - require.Equal(t, "tier", tier.Limits.Basis) - require.Equal(t, int64(500), tier.Prices.Month) - require.Equal(t, int64(5000), tier.Prices.Year) - require.Equal(t, int64(777), tier.Limits.Reservations) - require.Equal(t, int64(1000), tier.Limits.Messages) - require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration) - require.Equal(t, int64(123), tier.Limits.Emails) - require.Equal(t, int64(999), tier.Limits.AttachmentFileSize) - require.Equal(t, int64(888), tier.Limits.AttachmentTotalSize) - require.Equal(t, int64(60), tier.Limits.AttachmentExpiryDuration) + tier = tiers[1] + require.Equal(t, "pro", tier.Code) + require.Equal(t, "Pro", tier.Name) + require.Equal(t, "tier", tier.Limits.Basis) + require.Equal(t, int64(500), tier.Prices.Month) + require.Equal(t, int64(5000), tier.Prices.Year) + require.Equal(t, int64(777), tier.Limits.Reservations) + require.Equal(t, int64(1000), tier.Limits.Messages) + require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration) + require.Equal(t, int64(123), tier.Limits.Emails) + require.Equal(t, int64(999), tier.Limits.AttachmentFileSize) + require.Equal(t, int64(888), tier.Limits.AttachmentTotalSize) + require.Equal(t, int64(60), tier.Limits.AttachmentExpiryDuration) - tier = tiers[2] - require.Equal(t, "business", tier.Code) - require.Equal(t, "Business", tier.Name) - require.Equal(t, int64(1000), tier.Prices.Month) - require.Equal(t, int64(10000), tier.Prices.Year) - require.Equal(t, "tier", tier.Limits.Basis) - require.Equal(t, int64(777333), tier.Limits.Reservations) - require.Equal(t, int64(2000), tier.Limits.Messages) - require.Equal(t, int64(36000), tier.Limits.MessagesExpiryDuration) - require.Equal(t, int64(123123), tier.Limits.Emails) - require.Equal(t, int64(999111), tier.Limits.AttachmentFileSize) - require.Equal(t, int64(888111), tier.Limits.AttachmentTotalSize) - require.Equal(t, int64(3600), tier.Limits.AttachmentExpiryDuration) + tier = tiers[2] + require.Equal(t, "business", tier.Code) + require.Equal(t, "Business", tier.Name) + require.Equal(t, int64(1000), tier.Prices.Month) + require.Equal(t, int64(10000), tier.Prices.Year) + require.Equal(t, "tier", tier.Limits.Basis) + require.Equal(t, int64(777333), tier.Limits.Reservations) + require.Equal(t, int64(2000), tier.Limits.Messages) + require.Equal(t, int64(36000), tier.Limits.MessagesExpiryDuration) + require.Equal(t, int64(123123), tier.Limits.Emails) + require.Equal(t, int64(999111), tier.Limits.AttachmentFileSize) + require.Equal(t, int64(888111), tier.Limits.AttachmentTotalSize) + require.Equal(t, int64(3600), tier.Limits.AttachmentExpiryDuration) + }) } func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) { - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + forEachBackend(t, func(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("NewCheckoutSession", mock.Anything). - Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil) + // Define how the mock should react + stripeMock. + On("NewCheckoutSession", mock.Anything). + Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil) - // Create tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - StripeMonthlyPriceID: "price_123", - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + // Create tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + StripeMonthlyPriceID: "price_123", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - // Create subscription - response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Create subscription + response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + redirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, "https://billing.stripe.com/abc/def", redirectResponse.RedirectURL) }) - require.Equal(t, 200, response.Code) - redirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, "https://billing.stripe.com/abc/def", redirectResponse.RedirectURL) } func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) { - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + forEachBackend(t, func(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("GetCustomer", "acct_123"). - Return(&stripe.Customer{Subscriptions: &stripe.SubscriptionList{}}, nil) - stripeMock. - On("NewCheckoutSession", mock.Anything). - Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil) + // Define how the mock should react + stripeMock. + On("GetCustomer", "acct_123"). + Return(&stripe.Customer{Subscriptions: &stripe.SubscriptionList{}}, nil) + stripeMock. + On("NewCheckoutSession", mock.Anything). + Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil) - // Create tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - StripeMonthlyPriceID: "price_123", - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + // Create tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + StripeMonthlyPriceID: "price_123", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - u, err := s.userManager.User("phil") - require.Nil(t, err) + u, err := s.userManager.User("phil") + require.Nil(t, err) - billing := &user.Billing{ - StripeCustomerID: "acct_123", - } - require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) + billing := &user.Billing{ + StripeCustomerID: "acct_123", + } + require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) - // Create subscription - response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Create subscription + response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + redirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, "https://billing.stripe.com/abc/def", redirectResponse.RedirectURL) }) - require.Equal(t, 200, response.Code) - redirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, "https://billing.stripe.com/abc/def", redirectResponse.RedirectURL) } func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) { - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + forEachBackend(t, func(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.EnableSignup = true - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.EnableSignup = true + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("CancelSubscription", "sub_123"). - Return(&stripe.Subscription{}, nil) + // Define how the mock should react + stripeMock. + On("CancelSubscription", "sub_123"). + Return(&stripe.Subscription{}, nil) - // Create tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - StripeMonthlyPriceID: "price_123", - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + // Create tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + StripeMonthlyPriceID: "price_123", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - u, err := s.userManager.User("phil") - require.Nil(t, err) + u, err := s.userManager.User("phil") + require.Nil(t, err) - billing := &user.Billing{ - StripeCustomerID: "acct_123", - StripeSubscriptionID: "sub_123", - } - require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) + billing := &user.Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + } + require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) - // Delete account - rr := request(t, s, "DELETE", "/v1/account", `{"password": "phil"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Delete account + rr := request(t, s, "DELETE", "/v1/account", `{"password": "phil"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 401, rr.Code) }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "mypass"), - }) - require.Equal(t, 401, rr.Code) } func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *testing.T) { - // This test is too overloaded, but it's also a great end-to-end a test. - // - // It tests: - // - A successful checkout flow (not a paying customer -> paying customer) - // - Tier-changes reset the rate limits for the user - // - The request limits for tier-less user and a tier-user - // - The message limits for a tier-user + forEachBackend(t, func(t *testing.T) { + // This test is too overloaded, but it's also a great end-to-end a test. + // + // It tests: + // - A successful checkout flow (not a paying customer -> paying customer) + // - Tier-changes reset the rate limits for the user + // - The request limits for tier-less user and a tier-user + // - The message limits for a tier-user - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - c.VisitorRequestLimitBurst = 5 - c.VisitorRequestLimitReplenish = time.Hour - c.CacheBatchSize = 500 - c.CacheBatchTimeout = time.Second - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + c.VisitorRequestLimitBurst = 5 + c.VisitorRequestLimitReplenish = time.Hour + c.CacheBatchSize = 500 + c.CacheBatchTimeout = time.Second + s := newTestServer(t, c) + s.stripe = stripeMock - // Create a user with a Stripe subscription and 3 reservations - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "starter", - StripeMonthlyPriceID: "price_1234", - ReservationLimit: 1, - MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in - MessageExpiryDuration: time.Hour, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) // No tier - u, err := s.userManager.User("phil") - require.Nil(t, err) + // Create a user with a Stripe subscription and 3 reservations + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "starter", + StripeMonthlyPriceID: "price_1234", + ReservationLimit: 1, + MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in + MessageExpiryDuration: time.Hour, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) // No tier + u, err := s.userManager.User("phil") + require.Nil(t, err) - // Define how the mock should react - stripeMock. - On("GetSession", "SOMETOKEN"). - Return(&stripe.CheckoutSession{ - ClientReferenceID: u.ID, // ntfy user ID - Customer: &stripe.Customer{ - ID: "acct_5555", - }, - Subscription: &stripe.Subscription{ - ID: "sub_1234", - }, - }, nil) - stripeMock. - On("GetSubscription", "sub_1234"). - Return(&stripe.Subscription{ - ID: "sub_1234", - Status: stripe.SubscriptionStatusActive, - CurrentPeriodEnd: 123456789, - CancelAt: 0, - Items: &stripe.SubscriptionItemList{ - Data: []*stripe.SubscriptionItem{ - { - Price: &stripe.Price{ - ID: "price_1234", - Recurring: &stripe.PriceRecurring{ - Interval: stripe.PriceRecurringIntervalMonth, + // Define how the mock should react + stripeMock. + On("GetSession", "SOMETOKEN"). + Return(&stripe.CheckoutSession{ + ClientReferenceID: u.ID, // ntfy user ID + Customer: &stripe.Customer{ + ID: "acct_5555", + }, + Subscription: &stripe.Subscription{ + ID: "sub_1234", + }, + }, nil) + stripeMock. + On("GetSubscription", "sub_1234"). + Return(&stripe.Subscription{ + ID: "sub_1234", + Status: stripe.SubscriptionStatusActive, + CurrentPeriodEnd: 123456789, + CancelAt: 0, + Items: &stripe.SubscriptionItemList{ + Data: []*stripe.SubscriptionItem{ + { + Price: &stripe.Price{ + ID: "price_1234", + Recurring: &stripe.PriceRecurring{ + Interval: stripe.PriceRecurringIntervalMonth, + }, }, }, }, }, - }, - }, nil) - stripeMock. - On("UpdateCustomer", "acct_5555", &stripe.CustomerParams{ - Params: stripe.Params{ - Metadata: map[string]string{ - "user_id": u.ID, - "user_name": u.Name, + }, nil) + stripeMock. + On("UpdateCustomer", "acct_5555", &stripe.CustomerParams{ + Params: stripe.Params{ + Metadata: map[string]string{ + "user_id": u.ID, + "user_name": u.Name, + }, }, - }, - }). - Return(&stripe.Customer{}, nil) + }). + Return(&stripe.Customer{}, nil) - // Send messages until rate limit of free tier is hit - for i := 0; i < 5; i++ { - rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - } - rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 429, rr.Code) - - // Verify some "before-stats" - u, err = s.userManager.User("phil") - require.Nil(t, err) - require.Nil(t, u.Tier) - require.Equal(t, "", u.Billing.StripeCustomerID) - require.Equal(t, "", u.Billing.StripeSubscriptionID) - require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) - require.Equal(t, payments.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval) - require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) - require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) - require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users! - require.Equal(t, int64(0), u.Stats.Emails) - - // Simulate Stripe success return URL call (no user context) - rr = request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) - require.Equal(t, 303, rr.Code) - - // Verify that database columns were updated - u, err = s.userManager.User("phil") - require.Nil(t, err) - require.Equal(t, "starter", u.Tier.Code) // Not "pro" - require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) - require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) - require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) - require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval) - require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix()) - require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) - require.Equal(t, int64(0), u.Stats.Messages) - require.Equal(t, int64(0), u.Stats.Emails) - - // Now for the fun part: Verify that new rate limits are immediately applied - // This only tests the request limiter, which kicks in before the message limiter. - for i := 0; i < 11; i++ { - rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code, "failed on iteration %d", i) - } - rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 429, rr.Code) - - // Now let's test the message limiter by faking a ridiculously generous rate limiter - v := s.visitor(netip.MustParseAddr("9.9.9.9"), u) - v.requestLimiter = rate.NewLimiter(rate.Every(time.Millisecond), 1000000) - - var wg sync.WaitGroup - for i := 0; i < 209; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() + // Send messages until rate limit of free tier is hit + for i := 0; i < 5; i++ { rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), }) - require.Equal(t, 200, rr.Code, "Failed on %d", i) - }(i) - } - wg.Wait() - rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 429, rr.Code) + require.Equal(t, 200, rr.Code) + } + rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, rr.Code) - // And now let's cross-check that the stats are correct too - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Verify some "before-stats" + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, u.Tier) + require.Equal(t, "", u.Billing.StripeCustomerID) + require.Equal(t, "", u.Billing.StripeSubscriptionID) + require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) + require.Equal(t, payments.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) + require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users! + require.Equal(t, int64(0), u.Stats.Emails) + + // Simulate Stripe success return URL call (no user context) + rr = request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + + // Verify that database columns were updated + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Equal(t, "starter", u.Tier.Code) // Not "pro" + require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) + require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) + require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) + require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval) + require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix()) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) + require.Equal(t, int64(0), u.Stats.Messages) + require.Equal(t, int64(0), u.Stats.Emails) + + // Now for the fun part: Verify that new rate limits are immediately applied + // This only tests the request limiter, which kicks in before the message limiter. + for i := 0; i < 11; i++ { + rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code, "failed on iteration %d", i) + } + rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, rr.Code) + + // Now let's test the message limiter by faking a ridiculously generous rate limiter + v := s.visitor(netip.MustParseAddr("9.9.9.9"), u) + v.requestLimiter = rate.NewLimiter(rate.Every(time.Millisecond), 1000000) + + var wg sync.WaitGroup + for i := 0; i < 209; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code, "Failed on %d", i) + }(i) + } + wg.Wait() + rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, rr.Code) + + // And now let's cross-check that the stats are correct too + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(220), account.Limits.Messages) + require.Equal(t, int64(220), account.Stats.Messages) + require.Equal(t, int64(0), account.Stats.MessagesRemaining) }) - require.Equal(t, 200, rr.Code) - account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, int64(220), account.Limits.Messages) - require.Equal(t, int64(220), account.Stats.Messages) - require.Equal(t, int64(0), account.Stats.MessagesRemaining) } func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) { - t.Parallel() + forEachBackend(t, func(t *testing.T) { + t.Parallel() - // This tests incoming webhooks from Stripe to update a subscription: - // - All Stripe columns are updated in the user table - // - When downgrading, excess reservations are deleted, including messages and attachments in - // the corresponding topics + // This tests incoming webhooks from Stripe to update a subscription: + // - All Stripe columns are updated in the user table + // - When downgrading, excess reservations are deleted, including messages and attachments in + // the corresponding topics - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key"). - Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil) + // Define how the mock should react + stripeMock. + On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key"). + Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil) - // Create a user with a Stripe subscription and 3 reservations - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_1", - Code: "starter", - StripeMonthlyPriceID: "price_1234", // ! - ReservationLimit: 1, // ! - MessageLimit: 100, - MessageExpiryDuration: time.Hour, - AttachmentExpiryDuration: time.Hour, - AttachmentFileSizeLimit: 1000000, - AttachmentTotalSizeLimit: 1000000, - AttachmentBandwidthLimit: 1000000, - })) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_2", - Code: "pro", - StripeMonthlyPriceID: "price_1111", // ! - ReservationLimit: 3, // ! - MessageLimit: 200, - MessageExpiryDuration: time.Hour, - AttachmentExpiryDuration: time.Hour, - AttachmentFileSizeLimit: 1000000, - AttachmentTotalSizeLimit: 1000000, - AttachmentBandwidthLimit: 1000000, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll)) - require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll)) + // Create a user with a Stripe subscription and 3 reservations + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_1", + Code: "starter", + StripeMonthlyPriceID: "price_1234", // ! + ReservationLimit: 1, // ! + MessageLimit: 100, + MessageExpiryDuration: time.Hour, + AttachmentExpiryDuration: time.Hour, + AttachmentFileSizeLimit: 1000000, + AttachmentTotalSizeLimit: 1000000, + AttachmentBandwidthLimit: 1000000, + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_2", + Code: "pro", + StripeMonthlyPriceID: "price_1111", // ! + ReservationLimit: 3, // ! + MessageLimit: 200, + MessageExpiryDuration: time.Hour, + AttachmentExpiryDuration: time.Hour, + AttachmentFileSizeLimit: 1000000, + AttachmentTotalSizeLimit: 1000000, + AttachmentBandwidthLimit: 1000000, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll)) + require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll)) - // Add billing details - u, err := s.userManager.User("phil") - require.Nil(t, err) + // Add billing details + u, err := s.userManager.User("phil") + require.Nil(t, err) - billing := &user.Billing{ - StripeCustomerID: "acct_5555", - StripeSubscriptionID: "sub_1234", - StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue), - StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), - StripeSubscriptionPaidUntil: time.Unix(123, 0), - StripeSubscriptionCancelAt: time.Unix(456, 0), - } - require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) + billing := &user.Billing{ + StripeCustomerID: "acct_5555", + StripeSubscriptionID: "sub_1234", + StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue), + StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), + StripeSubscriptionPaidUntil: time.Unix(123, 0), + StripeSubscriptionCancelAt: time.Unix(456, 0), + } + require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) - // Add some messages to "atopic" and "ztopic", everything in "ztopic" will be deleted - rr := request(t, s, "PUT", "/atopic", "some aaa message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Add some messages to "atopic" and "ztopic", everything in "ztopic" will be deleted + rr := request(t, s, "PUT", "/atopic", "some aaa message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "PUT", "/atopic", strings.Repeat("a", 5000), map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + a2 := toMessage(t, rr.Body.String()) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID)) + + rr = request(t, s, "PUT", "/ztopic", "some zzz message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "PUT", "/ztopic", strings.Repeat("z", 5000), map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + z2 := toMessage(t, rr.Body.String()) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID)) + + // Call the webhook: This does all the magic + rr = request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{ + "Stripe-Signature": "stripe signature", + }) + require.Equal(t, 200, rr.Code) + + // Verify that database columns were updated + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Equal(t, "starter", u.Tier.Code) // Not "pro" + require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) + require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) + require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) // Not "past_due" + require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalYear), u.Billing.StripeSubscriptionInterval) // Not "month" + require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated + require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated + + // Verify that reservations were deleted + r, err := s.userManager.Reservations("phil") + require.Nil(t, err) + require.Equal(t, 1, len(r)) // "ztopic" reservation was deleted + require.Equal(t, "atopic", r[0].Topic) + + // Verify that messages and attachments were deleted + time.Sleep(time.Second) + s.execManager() + + ms, err := s.messageCache.Messages("atopic", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(ms)) + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID)) + + ms, err = s.messageCache.Messages("ztopic", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 0, len(ms)) + require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID)) }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "PUT", "/atopic", strings.Repeat("a", 5000), map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - a2 := toMessage(t, rr.Body.String()) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID)) - - rr = request(t, s, "PUT", "/ztopic", "some zzz message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - rr = request(t, s, "PUT", "/ztopic", strings.Repeat("z", 5000), map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - z2 := toMessage(t, rr.Body.String()) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID)) - - // Call the webhook: This does all the magic - rr = request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{ - "Stripe-Signature": "stripe signature", - }) - require.Equal(t, 200, rr.Code) - - // Verify that database columns were updated - u, err = s.userManager.User("phil") - require.Nil(t, err) - require.Equal(t, "starter", u.Tier.Code) // Not "pro" - require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) - require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) - require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) // Not "past_due" - require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalYear), u.Billing.StripeSubscriptionInterval) // Not "month" - require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated - require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated - - // Verify that reservations were deleted - r, err := s.userManager.Reservations("phil") - require.Nil(t, err) - require.Equal(t, 1, len(r)) // "ztopic" reservation was deleted - require.Equal(t, "atopic", r[0].Topic) - - // Verify that messages and attachments were deleted - time.Sleep(time.Second) - s.execManager() - - ms, err := s.messageCache.Messages("atopic", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 2, len(ms)) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID)) - - ms, err = s.messageCache.Messages("ztopic", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 0, len(ms)) - require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID)) } func TestPayments_Webhook_Subscription_Deleted(t *testing.T) { - // This tests incoming webhooks from Stripe to delete a subscription. It verifies that the database is - // updated (all Stripe fields are deleted, and the tier is removed). - // - // It doesn't fully test the message/attachment deletion. That is tested above in the subscription update call. + forEachBackend(t, func(t *testing.T) { + // This tests incoming webhooks from Stripe to delete a subscription. It verifies that the database is + // updated (all Stripe fields are deleted, and the tier is removed). + // + // It doesn't fully test the message/attachment deletion. That is tested above in the subscription update call. - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key"). - Return(jsonToStripeEvent(t, subscriptionDeletedEventJSON), nil) + // Define how the mock should react + stripeMock. + On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key"). + Return(jsonToStripeEvent(t, subscriptionDeletedEventJSON), nil) - // Create a user with a Stripe subscription and 3 reservations - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_1", - Code: "pro", - StripeMonthlyPriceID: "price_1234", - ReservationLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll)) + // Create a user with a Stripe subscription and 3 reservations + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_1", + Code: "pro", + StripeMonthlyPriceID: "price_1234", + ReservationLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll)) - // Add billing details - u, err := s.userManager.User("phil") - require.Nil(t, err) - require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{ - StripeCustomerID: "acct_5555", - StripeSubscriptionID: "sub_1234", - StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue), - StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), - StripeSubscriptionPaidUntil: time.Unix(123, 0), - StripeSubscriptionCancelAt: time.Unix(0, 0), - })) + // Add billing details + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{ + StripeCustomerID: "acct_5555", + StripeSubscriptionID: "sub_1234", + StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue), + StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), + StripeSubscriptionPaidUntil: time.Unix(123, 0), + StripeSubscriptionCancelAt: time.Unix(0, 0), + })) - // Call the webhook: This does all the magic - rr := request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{ - "Stripe-Signature": "stripe signature", + // Call the webhook: This does all the magic + rr := request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{ + "Stripe-Signature": "stripe signature", + }) + require.Equal(t, 200, rr.Code) + + // Verify that database columns were updated + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, u.Tier) + require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) + require.Equal(t, "", u.Billing.StripeSubscriptionID) + require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) + require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) + + // Verify that reservations were deleted + r, err := s.userManager.Reservations("phil") + require.Nil(t, err) + require.Equal(t, 0, len(r)) }) - require.Equal(t, 200, rr.Code) - - // Verify that database columns were updated - u, err = s.userManager.User("phil") - require.Nil(t, err) - require.Nil(t, u.Tier) - require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) - require.Equal(t, "", u.Billing.StripeSubscriptionID) - require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) - require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) - require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) - - // Verify that reservations were deleted - r, err := s.userManager.Reservations("phil") - require.Nil(t, err) - require.Equal(t, 0, len(r)) } func TestPayments_Subscription_Update_Different_Tier(t *testing.T) { - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + forEachBackend(t, func(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("GetSubscription", "sub_123"). - Return(&stripe.Subscription{ - ID: "sub_123", - Items: &stripe.SubscriptionItemList{ - Data: []*stripe.SubscriptionItem{ - { - ID: "someid_123", - Price: &stripe.Price{ID: "price_123"}, + // Define how the mock should react + stripeMock. + On("GetSubscription", "sub_123"). + Return(&stripe.Subscription{ + ID: "sub_123", + Items: &stripe.SubscriptionItemList{ + Data: []*stripe.SubscriptionItem{ + { + ID: "someid_123", + Price: &stripe.Price{ID: "price_123"}, + }, }, }, - }, - }, nil) - stripeMock. - On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{ - CancelAtPeriodEnd: stripe.Bool(false), - ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)), - Items: []*stripe.SubscriptionItemsParams{ - { - ID: stripe.String("someid_123"), - Price: stripe.String("price_457"), + }, nil) + stripeMock. + On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{ + CancelAtPeriodEnd: stripe.Bool(false), + ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)), + Items: []*stripe.SubscriptionItemsParams{ + { + ID: stripe.String("someid_123"), + Price: stripe.String("price_457"), + }, }, - }, - }). - Return(&stripe.Subscription{}, nil) + }). + Return(&stripe.Subscription{}, nil) - // Create tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - StripeMonthlyPriceID: "price_123", - StripeYearlyPriceID: "price_124", - })) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_456", - Code: "business", - StripeMonthlyPriceID: "price_456", - StripeYearlyPriceID: "price_457", - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ - StripeCustomerID: "acct_123", - StripeSubscriptionID: "sub_123", - })) + // Create tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", + Code: "pro", + StripeMonthlyPriceID: "price_123", + StripeYearlyPriceID: "price_124", + })) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_456", + Code: "business", + StripeMonthlyPriceID: "price_456", + StripeYearlyPriceID: "price_457", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + })) - // Call endpoint to change subscription - rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business","interval":"year"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Call endpoint to change subscription + rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business","interval":"year"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) }) - require.Equal(t, 200, rr.Code) } func TestPayments_Subscription_Delete_At_Period_End(t *testing.T) { - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + forEachBackend(t, func(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("UpdateSubscription", "sub_123", mock.MatchedBy(func(s *stripe.SubscriptionParams) bool { - return *s.CancelAtPeriodEnd // Is true - })). - Return(&stripe.Subscription{}, nil) + // Define how the mock should react + stripeMock. + On("UpdateSubscription", "sub_123", mock.MatchedBy(func(s *stripe.SubscriptionParams) bool { + return *s.CancelAtPeriodEnd // Is true + })). + Return(&stripe.Subscription{}, nil) - // Create user - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ - StripeCustomerID: "acct_123", - StripeSubscriptionID: "sub_123", - })) + // Create user + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + })) - // Delete subscription - rr := request(t, s, "DELETE", "/v1/account/billing/subscription", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Delete subscription + rr := request(t, s, "DELETE", "/v1/account/billing/subscription", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) }) - require.Equal(t, 200, rr.Code) } func TestPayments_CreatePortalSession(t *testing.T) { - stripeMock := &testStripeAPI{} - defer stripeMock.AssertExpectations(t) + forEachBackend(t, func(t *testing.T) { + stripeMock := &testStripeAPI{} + defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) - c.StripeSecretKey = "secret key" - c.StripeWebhookKey = "webhook key" - s := newTestServer(t, c) - s.stripe = stripeMock + c := newTestConfigWithAuthFile(t) + c.StripeSecretKey = "secret key" + c.StripeWebhookKey = "webhook key" + s := newTestServer(t, c) + s.stripe = stripeMock - // Define how the mock should react - stripeMock. - On("NewPortalSession", &stripe.BillingPortalSessionParams{ - Customer: stripe.String("acct_123"), - ReturnURL: stripe.String(s.config.BaseURL), - }). - Return(&stripe.BillingPortalSession{ - URL: "https://billing.stripe.com/blablabla", - }, nil) + // Define how the mock should react + stripeMock. + On("NewPortalSession", &stripe.BillingPortalSessionParams{ + Customer: stripe.String("acct_123"), + ReturnURL: stripe.String(s.config.BaseURL), + }). + Return(&stripe.BillingPortalSession{ + URL: "https://billing.stripe.com/blablabla", + }, nil) - // Create user - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ - StripeCustomerID: "acct_123", - StripeSubscriptionID: "sub_123", - })) + // Create user + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{ + StripeCustomerID: "acct_123", + StripeSubscriptionID: "sub_123", + })) - // Create portal session - rr := request(t, s, "POST", "/v1/account/billing/portal", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Create portal session + rr := request(t, s, "POST", "/v1/account/billing/portal", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + ps, _ := util.UnmarshalJSON[apiAccountBillingPortalRedirectResponse](io.NopCloser(rr.Body)) + require.Equal(t, "https://billing.stripe.com/blablabla", ps.RedirectURL) }) - require.Equal(t, 200, rr.Code) - ps, _ := util.UnmarshalJSON[apiAccountBillingPortalRedirectResponse](io.NopCloser(rr.Body)) - require.Equal(t, "https://billing.stripe.com/blablabla", ps.RedirectURL) } type testStripeAPI struct { diff --git a/server/server_test.go b/server/server_test.go index 23f72d1c..2a8930c9 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "crypto/rand" + "database/sql" _ "embed" "encoding/base64" "encoding/json" @@ -13,6 +14,7 @@ import ( "net/http" "net/http/httptest" "net/netip" + "net/url" "os" "path/filepath" "runtime/debug" @@ -22,6 +24,7 @@ import ( "testing" "time" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/v2/log" @@ -37,77 +40,84 @@ func TestMain(m *testing.M) { } func TestServer_PublishAndPoll(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response1 := request(t, s, "PUT", "/mytopic", "my first message", nil) - msg1 := toMessage(t, response1.Body.String()) - require.NotEmpty(t, msg1.ID) - require.Equal(t, "my first message", msg1.Message) + response1 := request(t, s, "PUT", "/mytopic", "my first message", nil) + msg1 := toMessage(t, response1.Body.String()) + require.NotEmpty(t, msg1.ID) + require.Equal(t, "my first message", msg1.Message) - response2 := request(t, s, "PUT", "/mytopic", "my second\n\nmessage", nil) - msg2 := toMessage(t, response2.Body.String()) - require.NotEqual(t, msg1.ID, msg2.ID) - require.NotEmpty(t, msg2.ID) - require.Equal(t, "my second\n\nmessage", msg2.Message) + response2 := request(t, s, "PUT", "/mytopic", "my second\n\nmessage", nil) + msg2 := toMessage(t, response2.Body.String()) + require.NotEqual(t, msg1.ID, msg2.ID) + require.NotEmpty(t, msg2.ID) + require.Equal(t, "my second\n\nmessage", msg2.Message) - response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 2, len(messages)) - require.Equal(t, "my first message", messages[0].Message) - require.Equal(t, "my second\n\nmessage", messages[1].Message) + response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 2, len(messages)) + require.Equal(t, "my first message", messages[0].Message) + require.Equal(t, "my second\n\nmessage", messages[1].Message) - response = request(t, s, "GET", "/mytopic/sse?poll=1&since=all", "", nil) - lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") - require.Equal(t, 3, len(lines)) - require.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message) - require.Equal(t, "", lines[1]) - require.Equal(t, "my second\n\nmessage", toMessage(t, strings.TrimPrefix(lines[2], "data: ")).Message) + response = request(t, s, "GET", "/mytopic/sse?poll=1&since=all", "", nil) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 3, len(lines)) + require.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message) + require.Equal(t, "", lines[1]) + require.Equal(t, "my second\n\nmessage", toMessage(t, strings.TrimPrefix(lines[2], "data: ")).Message) - response = request(t, s, "GET", "/mytopic/raw?poll=1", "", nil) - lines = strings.Split(strings.TrimSpace(response.Body.String()), "\n") - require.Equal(t, 2, len(lines)) - require.Equal(t, "my first message", lines[0]) - require.Equal(t, "my second message", lines[1]) // \n -> " " + response = request(t, s, "GET", "/mytopic/raw?poll=1", "", nil) + lines = strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + require.Equal(t, "my first message", lines[0]) + require.Equal(t, "my second message", lines[1]) // \n -> " " + }) } func TestServer_PublishWithFirebase(t *testing.T) { - sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) - s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + forEachBackend(t, func(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) - response := request(t, s, "PUT", "/mytopic", "my first message", nil) - msg1 := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg1.ID) - require.Equal(t, "my first message", msg1.Message) + response := request(t, s, "PUT", "/mytopic", "my first message", nil) + msg1 := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg1.ID) + require.Equal(t, "my first message", msg1.Message) - time.Sleep(100 * time.Millisecond) // Firebase publishing happens - require.Equal(t, 1, len(sender.Messages())) - require.Equal(t, "my first message", sender.Messages()[0].Data["message"]) - require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body) - require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"]) + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 1, len(sender.Messages())) + require.Equal(t, "my first message", sender.Messages()[0].Data["message"]) + require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body) + require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"]) + }) } func TestServer_PublishWithoutFirebase(t *testing.T) { - sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) - s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + forEachBackend(t, func(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) - response := request(t, s, "PUT", "/mytopic", "my first message", map[string]string{ - "firebase": "no", + response := request(t, s, "PUT", "/mytopic", "my first message", map[string]string{ + "firebase": "no", + }) + msg1 := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg1.ID) + require.Equal(t, "my first message", msg1.Message) + + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 0, len(sender.Messages())) }) - msg1 := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg1.ID) - require.Equal(t, "my first message", msg1.Message) - - time.Sleep(100 * time.Millisecond) // Firebase publishing happens - require.Equal(t, 0, len(sender.Messages())) } func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) { - // This tests issue #641, which used to panic before the fix + forEachBackend(t, func(t *testing.T) { + // This tests issue #641, which used to panic before the fix - firebaseKeyFile := filepath.Join(t.TempDir(), "firebase.json") - contents := `{ + firebaseKeyFile := filepath.Join(t.TempDir(), "firebase.json") + contents := `{ "type": "service_account", "project_id": "ntfy-test", "private_key_id": "fsfhskjdfhskdhfskdjfhsdf", @@ -120,482 +130,529 @@ func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) { "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-muv04%40ntfy-test.iam.gserviceaccount.com" } ` - require.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600)) - c := newTestConfig(t) - c.FirebaseKeyFile = firebaseKeyFile - s := newTestServer(t, c) + require.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600)) + c := newTestConfig(t) + c.FirebaseKeyFile = firebaseKeyFile + s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", "my first message", nil) - require.Equal(t, "my first message", toMessage(t, response.Body.String()).Message) + response := request(t, s, "PUT", "/mytopic", "my first message", nil) + require.Equal(t, "my first message", toMessage(t, response.Body.String()).Message) + }) } func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.KeepaliveInterval = time.Second - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.KeepaliveInterval = time.Second + s := newTestServer(t, c) - rr := httptest.NewRecorder() - ctx, cancel := context.WithCancel(context.Background()) - req, err := http.NewRequestWithContext(ctx, "GET", "/mytopic/json", nil) - if err != nil { - t.Fatal(err) - } - doneChan := make(chan bool) - go func() { - s.handle(rr, req) - doneChan <- true - }() - time.Sleep(1300 * time.Millisecond) - cancel() - <-doneChan + rr := httptest.NewRecorder() + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, "GET", "/mytopic/json", nil) + if err != nil { + t.Fatal(err) + } + doneChan := make(chan bool) + go func() { + s.handle(rr, req) + doneChan <- true + }() + time.Sleep(1300 * time.Millisecond) + cancel() + <-doneChan - messages := toMessages(t, rr.Body.String()) - require.Equal(t, 2, len(messages)) + messages := toMessages(t, rr.Body.String()) + require.Equal(t, 2, len(messages)) - require.Equal(t, openEvent, messages[0].Event) - require.Equal(t, "mytopic", messages[0].Topic) - require.Equal(t, "", messages[0].Message) - require.Equal(t, "", messages[0].Title) - require.Equal(t, 0, messages[0].Priority) - require.Nil(t, messages[0].Tags) + require.Equal(t, openEvent, messages[0].Event) + require.Equal(t, "mytopic", messages[0].Topic) + require.Equal(t, "", messages[0].Message) + require.Equal(t, "", messages[0].Title) + require.Equal(t, 0, messages[0].Priority) + require.Nil(t, messages[0].Tags) - require.Equal(t, keepaliveEvent, messages[1].Event) - require.Equal(t, "mytopic", messages[1].Topic) - require.Equal(t, "", messages[1].Message) - require.Equal(t, "", messages[1].Title) - require.Equal(t, 0, messages[1].Priority) - require.Nil(t, messages[1].Tags) + require.Equal(t, keepaliveEvent, messages[1].Event) + require.Equal(t, "mytopic", messages[1].Topic) + require.Equal(t, "", messages[1].Message) + require.Equal(t, "", messages[1].Title) + require.Equal(t, 0, messages[1].Priority) + require.Nil(t, messages[1].Tags) + }) } func TestServer_PublishAndSubscribe(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - subscribeRR := httptest.NewRecorder() - subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR) + subscribeRR := httptest.NewRecorder() + subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR) - publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil) - require.Equal(t, 200, publishFirstRR.Code) - time.Sleep(500 * time.Millisecond) // Publishing is done asynchronously, this avoids races + publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil) + require.Equal(t, 200, publishFirstRR.Code) + time.Sleep(500 * time.Millisecond) // Publishing is done asynchronously, this avoids races - publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{ - "Title": " This is a title ", - "X-Tags": "tag1,tag 2, tag3", - "p": "1", + publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{ + "Title": " This is a title ", + "X-Tags": "tag1,tag 2, tag3", + "p": "1", + }) + require.Equal(t, 200, publishSecondRR.Code) + + subscribeCancel() + messages := toMessages(t, subscribeRR.Body.String()) + require.Equal(t, 3, len(messages)) + require.Equal(t, openEvent, messages[0].Event) + + require.Equal(t, messageEvent, messages[1].Event) + require.Equal(t, "mytopic", messages[1].Topic) + require.Equal(t, "my first message", messages[1].Message) + require.Equal(t, "", messages[1].Title) + require.Equal(t, 0, messages[1].Priority) + require.Nil(t, messages[1].Tags) + require.True(t, time.Now().Add(12*time.Hour-5*time.Second).Unix() < messages[1].Expires) + require.True(t, time.Now().Add(12*time.Hour+5*time.Second).Unix() > messages[1].Expires) + + require.Equal(t, messageEvent, messages[2].Event) + require.Equal(t, "mytopic", messages[2].Topic) + require.Equal(t, "my other message", messages[2].Message) + require.Equal(t, "This is a title", messages[2].Title) + require.Equal(t, 1, messages[2].Priority) + require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags) }) - require.Equal(t, 200, publishSecondRR.Code) - - subscribeCancel() - messages := toMessages(t, subscribeRR.Body.String()) - require.Equal(t, 3, len(messages)) - require.Equal(t, openEvent, messages[0].Event) - - require.Equal(t, messageEvent, messages[1].Event) - require.Equal(t, "mytopic", messages[1].Topic) - require.Equal(t, "my first message", messages[1].Message) - require.Equal(t, "", messages[1].Title) - require.Equal(t, 0, messages[1].Priority) - require.Nil(t, messages[1].Tags) - require.True(t, time.Now().Add(12*time.Hour-5*time.Second).Unix() < messages[1].Expires) - require.True(t, time.Now().Add(12*time.Hour+5*time.Second).Unix() > messages[1].Expires) - - require.Equal(t, messageEvent, messages[2].Event) - require.Equal(t, "mytopic", messages[2].Topic) - require.Equal(t, "my other message", messages[2].Message) - require.Equal(t, "This is a title", messages[2].Title) - require.Equal(t, 1, messages[2].Priority) - require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags) } func TestServer_Publish_Disallowed_Topic(t *testing.T) { - c := newTestConfig(t) - c.DisallowedTopics = []string{"about", "time", "this", "got", "added"} - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.DisallowedTopics = []string{"about", "time", "this", "got", "added"} + s := newTestServer(t, c) - rr := request(t, s, "PUT", "/mytopic", "my first message", nil) - require.Equal(t, 200, rr.Code) + rr := request(t, s, "PUT", "/mytopic", "my first message", nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "PUT", "/about", "another message", nil) - require.Equal(t, 400, rr.Code) - require.Equal(t, 40010, toHTTPError(t, rr.Body.String()).Code) + rr = request(t, s, "PUT", "/about", "another message", nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40010, toHTTPError(t, rr.Body.String()).Code) + }) } func TestServer_StaticSites(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - rr := request(t, s, "GET", "/", "", nil) - require.Equal(t, 200, rr.Code) - require.Contains(t, rr.Body.String(), "") + rr := request(t, s, "GET", "/", "", nil) + require.Equal(t, 200, rr.Code) + require.Contains(t, rr.Body.String(), "") - rr = request(t, s, "HEAD", "/", "", nil) - require.Equal(t, 200, rr.Code) + rr = request(t, s, "HEAD", "/", "", nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "OPTIONS", "/", "", nil) - require.Equal(t, 200, rr.Code) + rr = request(t, s, "OPTIONS", "/", "", nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s, "GET", "/does-not-exist.txt", "", nil) - require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/does-not-exist.txt", "", nil) + require.Equal(t, 404, rr.Code) - rr = request(t, s, "GET", "/mytopic", "", nil) - require.Equal(t, 200, rr.Code) - require.Contains(t, rr.Body.String(), ``) + rr = request(t, s, "GET", "/mytopic", "", nil) + require.Equal(t, 200, rr.Code) + require.Contains(t, rr.Body.String(), ``) - rr = request(t, s, "GET", "/docs", "", nil) - require.Equal(t, 301, rr.Code) + rr = request(t, s, "GET", "/docs", "", nil) + require.Equal(t, 301, rr.Code) - // Docs test removed, it was failing annoyingly. + // Docs test removed, it was failing annoyingly. + }) } func TestServer_WebEnabled(t *testing.T) { - conf := newTestConfig(t) - conf.WebRoot = "" // Disable web app - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfig(t) + conf.WebRoot = "" // Disable web app + s := newTestServer(t, conf) - rr := request(t, s, "GET", "/", "", nil) - require.Equal(t, 404, rr.Code) + rr := request(t, s, "GET", "/", "", nil) + require.Equal(t, 404, rr.Code) - rr = request(t, s, "GET", "/config.js", "", nil) - require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/config.js", "", nil) + require.Equal(t, 404, rr.Code) - rr = request(t, s, "GET", "/sw.js", "", nil) - require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/sw.js", "", nil) + require.Equal(t, 404, rr.Code) - rr = request(t, s, "GET", "/app.html", "", nil) - require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/app.html", "", nil) + require.Equal(t, 404, rr.Code) - rr = request(t, s, "GET", "/static/css/home.css", "", nil) - require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/static/css/home.css", "", nil) + require.Equal(t, 404, rr.Code) - conf2 := newTestConfig(t) - conf2.WebRoot = "/" - s2 := newTestServer(t, conf2) + conf2 := newTestConfig(t) + conf2.WebRoot = "/" + s2 := newTestServer(t, conf2) - rr = request(t, s2, "GET", "/", "", nil) - require.Equal(t, 200, rr.Code) + rr = request(t, s2, "GET", "/", "", nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s2, "GET", "/config.js", "", nil) - require.Equal(t, 200, rr.Code) + rr = request(t, s2, "GET", "/config.js", "", nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s2, "GET", "/sw.js", "", nil) - require.Equal(t, 200, rr.Code) + rr = request(t, s2, "GET", "/sw.js", "", nil) + require.Equal(t, 200, rr.Code) - rr = request(t, s2, "GET", "/app.html", "", nil) - require.Equal(t, 200, rr.Code) + rr = request(t, s2, "GET", "/app.html", "", nil) + require.Equal(t, 200, rr.Code) + }) } func TestServer_PublishLargeMessage(t *testing.T) { - c := newTestConfig(t) - c.AttachmentCacheDir = "" // Disable attachments - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.AttachmentCacheDir = "" // Disable attachments + s := newTestServer(t, c) - body := strings.Repeat("this is a large message", 5000) - response := request(t, s, "PUT", "/mytopic", body, nil) - require.Equal(t, 400, response.Code) + body := strings.Repeat("this is a large message", 5000) + response := request(t, s, "PUT", "/mytopic", body, nil) + require.Equal(t, 400, response.Code) + }) } func TestServer_PublishPriority(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - for prio := 1; prio <= 5; prio++ { - response := request(t, s, "GET", fmt.Sprintf("/mytopic/publish?priority=%d", prio), fmt.Sprintf("priority %d", prio), nil) - msg := toMessage(t, response.Body.String()) - require.Equal(t, prio, msg.Priority) - } + for prio := 1; prio <= 5; prio++ { + response := request(t, s, "GET", fmt.Sprintf("/mytopic/publish?priority=%d", prio), fmt.Sprintf("priority %d", prio), nil) + msg := toMessage(t, response.Body.String()) + require.Equal(t, prio, msg.Priority) + } - response := request(t, s, "GET", "/mytopic/publish?priority=min", "test", nil) - require.Equal(t, 1, toMessage(t, response.Body.String()).Priority) + response := request(t, s, "GET", "/mytopic/publish?priority=min", "test", nil) + require.Equal(t, 1, toMessage(t, response.Body.String()).Priority) - response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil) - require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) + response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil) + require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) - response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil) - require.Equal(t, 3, toMessage(t, response.Body.String()).Priority) + response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil) + require.Equal(t, 3, toMessage(t, response.Body.String()).Priority) - response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil) - require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) + response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil) + require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) - response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil) + require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) + require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) - require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) + response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) + require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_PublishPriority_SpecialHTTPHeader(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "Priority": "u=4", - "X-Priority": "5", - }) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Priority": "u=4", + "X-Priority": "5", + }) + require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - response = request(t, s, "POST", "/mytopic?priority=4", "test", map[string]string{ - "Priority": "u=9", - }) - require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) + response = request(t, s, "POST", "/mytopic?priority=4", "test", map[string]string{ + "Priority": "u=9", + }) + require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) - response = request(t, s, "POST", "/mytopic", "test", map[string]string{ - "p": "2", - "priority": "u=9, i", + response = request(t, s, "POST", "/mytopic", "test", map[string]string{ + "p": "2", + "priority": "u=9, i", + }) + require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) }) - require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) } func TestServer_PublishGETOnlyOneTopic(t *testing.T) { - // This tests a bug that allowed publishing topics with a comma in the name (no ticket) + forEachBackend(t, func(t *testing.T) { + // This tests a bug that allowed publishing topics with a comma in the name (no ticket) - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "GET", "/mytopic,mytopic2/publish?m=hi", "", nil) - require.Equal(t, 404, response.Code) + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "GET", "/mytopic,mytopic2/publish?m=hi", "", nil) + require.Equal(t, 404, response.Code) + }) } func TestServer_PublishNoCache(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "this message is not cached", map[string]string{ - "Cache": "no", + response := request(t, s, "PUT", "/mytopic", "this message is not cached", map[string]string{ + "Cache": "no", + }) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "this message is not cached", msg.Message) + require.Equal(t, int64(0), msg.Expires) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Empty(t, messages) }) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "this message is not cached", msg.Message) - require.Equal(t, int64(0), msg.Expires) - - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Empty(t, messages) } func TestServer_PublishAt(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "In": "1h", + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "1h", + }) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 0, len(messages)) + + // Update message time to the past + fakeTime := time.Now().Add(-10 * time.Second).Unix() + require.Nil(t, s.messageCache.UpdateMessageTime(msg.ID, fakeTime)) + + // Trigger delayed message sending + require.Nil(t, s.sendDelayedMessages()) + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "a message", messages[0].Message) + require.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender! + + var err error + messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "a message", messages[0].Message) + require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though! }) - require.Equal(t, 200, response.Code) - - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 0, len(messages)) - - // Update message time to the past - fakeTime := time.Now().Add(-10 * time.Second).Unix() - _, err := s.messageCache.DB().Exec(`UPDATE messages SET time=?`, fakeTime) - require.Nil(t, err) - - // Trigger delayed message sending - require.Nil(t, s.sendDelayedMessages()) - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "a message", messages[0].Message) - require.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender! - - messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "a message", messages[0].Message) - require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though! } func TestServer_PublishAt_FromUser(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfigWithAuthFile(t)) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "In": "1h", + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "In": "1h", + }) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + + // Message doesn't show up immediately + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 0, len(messages)) + + // Update message time to the past + fakeTime := time.Now().Add(-10 * time.Second).Unix() + require.Nil(t, s.messageCache.UpdateMessageTime(msg.ID, fakeTime)) + + // Trigger delayed message sending + require.Nil(t, s.sendDelayedMessages()) + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, fakeTime, messages[0].Time) + require.Equal(t, "a message", messages[0].Message) + + var err error + messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "a message", messages[0].Message) + require.True(t, strings.HasPrefix(messages[0].User, "u_")) }) - require.Equal(t, 200, response.Code) - - // Message doesn't show up immediately - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 0, len(messages)) - - // Update message time to the past - fakeTime := time.Now().Add(-10 * time.Second).Unix() - _, err := s.messageCache.DB().Exec(`UPDATE messages SET time=?`, fakeTime) - require.Nil(t, err) - - // Trigger delayed message sending - require.Nil(t, s.sendDelayedMessages()) - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, fakeTime, messages[0].Time) - require.Equal(t, "a message", messages[0].Message) - - messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "a message", messages[0].Message) - require.True(t, strings.HasPrefix(messages[0].User, "u_")) } func TestServer_PublishAt_Expires(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "In": "2 days", + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "2 days", + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.True(t, m.Expires > time.Now().Add(12*time.Hour+48*time.Hour-time.Minute).Unix()) + require.True(t, m.Expires < time.Now().Add(12*time.Hour+48*time.Hour+time.Minute).Unix()) }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.True(t, m.Expires > time.Now().Add(12*time.Hour+48*time.Hour-time.Minute).Unix()) - require.True(t, m.Expires < time.Now().Add(12*time.Hour+48*time.Hour+time.Minute).Unix()) } func TestServer_PublishAtWithCacheError(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "Cache": "no", - "In": "30 min", + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "Cache": "no", + "In": "30 min", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String())) }) - require.Equal(t, 400, response.Code) - require.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String())) } func TestServer_PublishAtTooShortDelay(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "In": "1s", + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "1s", + }) + require.Equal(t, 400, response.Code) }) - require.Equal(t, 400, response.Code) } func TestServer_PublishAtTooLongDelay(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "In": "99999999h", + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "99999999h", + }) + require.Equal(t, 400, response.Code) }) - require.Equal(t, 400, response.Code) } func TestServer_PublishAtInvalidDelay(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?delay=INVALID", "a message", nil) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 400, response.Code) - require.Equal(t, 40004, err.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?delay=INVALID", "a message", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 40004, err.Code) + }) } func TestServer_PublishAtTooLarge(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?x-in=99999h", "a message", nil) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 400, response.Code) - require.Equal(t, 40006, err.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?x-in=99999h", "a message", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 40006, err.Code) + }) } func TestServer_PublishAtAndPrune(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "In": "1h", + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "1h", + }) + require.Equal(t, 200, response.Code) + s.execManager() // Fire pruning + + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) // Not affected by pruning + require.Equal(t, "a message", messages[0].Message) + + time.Sleep(time.Second) // FIXME CI failing not sure why }) - require.Equal(t, 200, response.Code) - s.execManager() // Fire pruning - - response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) // Not affected by pruning - require.Equal(t, "a message", messages[0].Message) - - time.Sleep(time.Second) // FIXME CI failing not sure why } func TestServer_PublishAndMultiPoll(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic1", "message 1", nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "mytopic1", msg.Topic) - require.Equal(t, "message 1", msg.Message) + response := request(t, s, "PUT", "/mytopic1", "message 1", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "mytopic1", msg.Topic) + require.Equal(t, "message 1", msg.Message) - response = request(t, s, "PUT", "/mytopic2", "message 2", nil) - msg = toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "mytopic2", msg.Topic) - require.Equal(t, "message 2", msg.Message) + response = request(t, s, "PUT", "/mytopic2", "message 2", nil) + msg = toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "mytopic2", msg.Topic) + require.Equal(t, "message 2", msg.Message) - response = request(t, s, "GET", "/mytopic1/json?poll=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "mytopic1", messages[0].Topic) - require.Equal(t, "message 1", messages[0].Message) + response = request(t, s, "GET", "/mytopic1/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "mytopic1", messages[0].Topic) + require.Equal(t, "message 1", messages[0].Message) - response = request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1", "", nil) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 2, len(messages)) - require.Equal(t, "mytopic1", messages[0].Topic) - require.Equal(t, "message 1", messages[0].Message) - require.Equal(t, "mytopic2", messages[1].Topic) - require.Equal(t, "message 2", messages[1].Message) + response = request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 2, len(messages)) + require.Equal(t, "mytopic1", messages[0].Topic) + require.Equal(t, "message 1", messages[0].Message) + require.Equal(t, "mytopic2", messages[1].Topic) + require.Equal(t, "message 2", messages[1].Message) + }) } func TestServer_PublishWithNopCache(t *testing.T) { - c := newTestConfig(t) - c.CacheDuration = 0 - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.CacheDuration = 0 + s := newTestServer(t, c) - subscribeRR := httptest.NewRecorder() - subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR) + subscribeRR := httptest.NewRecorder() + subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR) - publishRR := request(t, s, "PUT", "/mytopic", "my first message", nil) - require.Equal(t, 200, publishRR.Code) + publishRR := request(t, s, "PUT", "/mytopic", "my first message", nil) + require.Equal(t, 200, publishRR.Code) - subscribeCancel() - messages := toMessages(t, subscribeRR.Body.String()) - require.Equal(t, 2, len(messages)) - require.Equal(t, openEvent, messages[0].Event) - require.Equal(t, messageEvent, messages[1].Event) - require.Equal(t, "my first message", messages[1].Message) + subscribeCancel() + messages := toMessages(t, subscribeRR.Body.String()) + require.Equal(t, 2, len(messages)) + require.Equal(t, openEvent, messages[0].Event) + require.Equal(t, messageEvent, messages[1].Event) + require.Equal(t, "my first message", messages[1].Message) - response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages = toMessages(t, response.Body.String()) - require.Empty(t, messages) + response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages = toMessages(t, response.Body.String()) + require.Empty(t, messages) + }) } func TestServer_PublishAndPollSince(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - request(t, s, "PUT", "/mytopic", "test 1", nil) - time.Sleep(1100 * time.Millisecond) + request(t, s, "PUT", "/mytopic", "test 1", nil) + time.Sleep(1100 * time.Millisecond) - since := time.Now().Unix() - request(t, s, "PUT", "/mytopic", "test 2", nil) + since := time.Now().Unix() + request(t, s, "PUT", "/mytopic", "test 2", nil) - response := request(t, s, "GET", fmt.Sprintf("/mytopic/json?poll=1&since=%d", since), "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "test 2", messages[0].Message) + response := request(t, s, "GET", fmt.Sprintf("/mytopic/json?poll=1&since=%d", since), "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "test 2", messages[0].Message) - response = request(t, s, "GET", "/mytopic/json?poll=1&since=10s", "", nil) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 2, len(messages)) - require.Equal(t, "test 1", messages[0].Message) + response = request(t, s, "GET", "/mytopic/json?poll=1&since=10s", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 2, len(messages)) + require.Equal(t, "test 1", messages[0].Message) - response = request(t, s, "GET", "/mytopic/json?poll=1&since=100ms", "", nil) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "test 2", messages[0].Message) + response = request(t, s, "GET", "/mytopic/json?poll=1&since=100ms", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "test 2", messages[0].Message) - response = request(t, s, "GET", "/mytopic/json?poll=1&since=latest", "", nil) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "test 2", messages[0].Message) + response = request(t, s, "GET", "/mytopic/json?poll=1&since=latest", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "test 2", messages[0].Message) - response = request(t, s, "GET", "/mytopic/json?poll=1&since=INVALID", "", nil) - require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code) + response = request(t, s, "GET", "/mytopic/json?poll=1&since=INVALID", "", nil) + require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code) + }) } func newMessageWithTimestamp(topic, msg string, timestamp int64) *model.Message { @@ -605,605 +662,658 @@ func newMessageWithTimestamp(topic, msg string, timestamp int64) *model.Message } func TestServer_PollSinceID_MultipleTopics(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 1", 1655740277))) - markerMessage := newMessageWithTimestamp("mytopic2", "test 2", 1655740283) - require.Nil(t, s.messageCache.AddMessage(markerMessage)) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289))) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293))) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297))) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 1", 1655740277))) + markerMessage := newMessageWithTimestamp("mytopic2", "test 2", 1655740283) + require.Nil(t, s.messageCache.AddMessage(markerMessage)) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303))) - response := request(t, s, "GET", fmt.Sprintf("/mytopic1,mytopic2/json?poll=1&since=%s", markerMessage.ID), "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 4, len(messages)) - require.Equal(t, "test 3", messages[0].Message) - require.Equal(t, "mytopic1", messages[0].Topic) - require.Equal(t, "test 4", messages[1].Message) - require.Equal(t, "mytopic2", messages[1].Topic) - require.Equal(t, "test 5", messages[2].Message) - require.Equal(t, "mytopic1", messages[2].Topic) - require.Equal(t, "test 6", messages[3].Message) - require.Equal(t, "mytopic2", messages[3].Topic) + response := request(t, s, "GET", fmt.Sprintf("/mytopic1,mytopic2/json?poll=1&since=%s", markerMessage.ID), "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 4, len(messages)) + require.Equal(t, "test 3", messages[0].Message) + require.Equal(t, "mytopic1", messages[0].Topic) + require.Equal(t, "test 4", messages[1].Message) + require.Equal(t, "mytopic2", messages[1].Topic) + require.Equal(t, "test 5", messages[2].Message) + require.Equal(t, "mytopic1", messages[2].Topic) + require.Equal(t, "test 6", messages[3].Message) + require.Equal(t, "mytopic2", messages[3].Topic) + }) } func TestServer_PollSinceID_MultipleTopics_IDDoesNotMatch(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289))) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293))) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297))) - require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297))) + require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303))) - response := request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1&since=NoMatchForID", "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 4, len(messages)) - require.Equal(t, "test 3", messages[0].Message) - require.Equal(t, "test 4", messages[1].Message) - require.Equal(t, "test 5", messages[2].Message) - require.Equal(t, "test 6", messages[3].Message) + response := request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1&since=NoMatchForID", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 4, len(messages)) + require.Equal(t, "test 3", messages[0].Message) + require.Equal(t, "test 4", messages[1].Message) + require.Equal(t, "test 5", messages[2].Message) + require.Equal(t, "test 6", messages[3].Message) + }) } func TestServer_PublishViaGET(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "GET", "/mytopic/trigger", "", nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "triggered", msg.Message) + response := request(t, s, "GET", "/mytopic/trigger", "", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "triggered", msg.Message) - response = request(t, s, "GET", "/mytopic/send?message=This+is+a+test&t=This+is+a+title&tags=skull&x-priority=5&delay=24h", "", nil) - msg = toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "This is a test", msg.Message) - require.Equal(t, "This is a title", msg.Title) - require.Equal(t, []string{"skull"}, msg.Tags) - require.Equal(t, 5, msg.Priority) - require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix()) + response = request(t, s, "GET", "/mytopic/send?message=This+is+a+test&t=This+is+a+title&tags=skull&x-priority=5&delay=24h", "", nil) + msg = toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "This is a test", msg.Message) + require.Equal(t, "This is a title", msg.Title) + require.Equal(t, []string{"skull"}, msg.Tags) + require.Equal(t, 5, msg.Priority) + require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix()) + }) } func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "", map[string]string{ - "Message": "Line 1\\nLine 2", + response := request(t, s, "PUT", "/mytopic", "", map[string]string{ + "Message": "Line 1\\nLine 2", + }) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n ! }) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n ! } func TestServer_PublishInvalidTopic(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - s.smtpSender = &testMailer{} - response := request(t, s, "PUT", "/docs", "fail", nil) - require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + s.smtpSender = &testMailer{} + response := request(t, s, "PUT", "/docs", "fail", nil) + require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_PublishWithSIDInPath(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic/sid", "message", nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "sid", msg.SequenceID) + response := request(t, s, "POST", "/mytopic/sid", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid", msg.SequenceID) + }) } func TestServer_PublishWithSIDInHeader(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", "message", map[string]string{ - "sid": "sid", + response := request(t, s, "POST", "/mytopic", "message", map[string]string{ + "sid": "sid", + }) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid", msg.SequenceID) }) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "sid", msg.SequenceID) } func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{ - "sid": "sid2", + response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{ + "sid": "sid2", + }) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SequenceID) // Sequence ID in path has priority over header }) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SequenceID) // Sequence ID in path has priority over header } func TestServer_PublishWithSIDInQuery(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SequenceID) + response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SequenceID) + }) } func TestServer_PublishWithSIDViaGet(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "sid1", msg.SequenceID) + response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "sid1", msg.SequenceID) + }) } func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}` - response := request(t, s, "PUT", "/", body, nil) - require.Equal(t, 200, response.Code) + body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "my-sequence-123", msg.SequenceID) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "my-sequence-123", msg.SequenceID) + }) } func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic/.", "message", nil) + response := request(t, s, "POST", "/mytopic/.", "message", nil) - require.Equal(t, 404, response.Code) + require.Equal(t, 404, response.Code) + }) } func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", "message", map[string]string{ - "X-Sequence-ID": "*&?", + response := request(t, s, "POST", "/mytopic", "message", map[string]string{ + "X-Sequence-ID": "*&?", + }) + + require.Equal(t, 400, response.Code) + require.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code) }) - - require.Equal(t, 400, response.Code) - require.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code) } func TestServer_PollWithQueryFilters(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?priority=1&tags=tag1,tag2", "my first message", nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) + response := request(t, s, "PUT", "/mytopic?priority=1&tags=tag1,tag2", "my first message", nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) - response = request(t, s, "PUT", "/mytopic?title=a+title", "my second message", map[string]string{ - "Tags": "tag2,tag3", + response = request(t, s, "PUT", "/mytopic?title=a+title", "my second message", map[string]string{ + "Tags": "tag2,tag3", + }) + msg = toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + + queriesThatShouldReturnMessageOne := []string{ + "/mytopic/json?poll=1&priority=1", + "/mytopic/json?poll=1&priority=min", + "/mytopic/json?poll=1&priority=min,low", + "/mytopic/json?poll=1&priority=1,2", + "/mytopic/json?poll=1&p=2,min", + "/mytopic/json?poll=1&tags=tag1", + "/mytopic/json?poll=1&tags=tag1,tag2", + "/mytopic/json?poll=1&message=my+first+message", + } + for _, query := range queriesThatShouldReturnMessageOne { + response = request(t, s, "GET", query, "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages), "Query failed: "+query) + require.Equal(t, "my first message", messages[0].Message, "Query failed: "+query) + } + + queriesThatShouldReturnMessageTwo := []string{ + "/mytopic/json?poll=1&x-priority=3", // ! + "/mytopic/json?poll=1&priority=3", + "/mytopic/json?poll=1&priority=default", + "/mytopic/json?poll=1&p=3", + "/mytopic/json?poll=1&x-tags=tag2,tag3", + "/mytopic/json?poll=1&tags=tag2,tag3", + "/mytopic/json?poll=1&tag=tag2,tag3", + "/mytopic/json?poll=1&ta=tag2,tag3", + "/mytopic/json?poll=1&x-title=a+title", + "/mytopic/json?poll=1&title=a+title", + "/mytopic/json?poll=1&t=a+title", + "/mytopic/json?poll=1&x-message=my+second+message", + "/mytopic/json?poll=1&message=my+second+message", + "/mytopic/json?poll=1&m=my+second+message", + "/mytopic/json?x-poll=1&m=my+second+message", + "/mytopic/json?po=1&m=my+second+message", + } + for _, query := range queriesThatShouldReturnMessageTwo { + response = request(t, s, "GET", query, "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages), "Query failed: "+query) + require.Equal(t, "my second message", messages[0].Message, "Query failed: "+query) + } + + queriesThatShouldReturnNoMessages := []string{ + "/mytopic/json?poll=1&priority=4", + "/mytopic/json?poll=1&tags=tag1,tag2,tag3", + "/mytopic/json?poll=1&title=another+title", + "/mytopic/json?poll=1&message=my+third+message", + "/mytopic/json?poll=1&message=my+third+message", + } + for _, query := range queriesThatShouldReturnNoMessages { + response = request(t, s, "GET", query, "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 0, len(messages), "Query failed: "+query) + } }) - msg = toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - - queriesThatShouldReturnMessageOne := []string{ - "/mytopic/json?poll=1&priority=1", - "/mytopic/json?poll=1&priority=min", - "/mytopic/json?poll=1&priority=min,low", - "/mytopic/json?poll=1&priority=1,2", - "/mytopic/json?poll=1&p=2,min", - "/mytopic/json?poll=1&tags=tag1", - "/mytopic/json?poll=1&tags=tag1,tag2", - "/mytopic/json?poll=1&message=my+first+message", - } - for _, query := range queriesThatShouldReturnMessageOne { - response = request(t, s, "GET", query, "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages), "Query failed: "+query) - require.Equal(t, "my first message", messages[0].Message, "Query failed: "+query) - } - - queriesThatShouldReturnMessageTwo := []string{ - "/mytopic/json?poll=1&x-priority=3", // ! - "/mytopic/json?poll=1&priority=3", - "/mytopic/json?poll=1&priority=default", - "/mytopic/json?poll=1&p=3", - "/mytopic/json?poll=1&x-tags=tag2,tag3", - "/mytopic/json?poll=1&tags=tag2,tag3", - "/mytopic/json?poll=1&tag=tag2,tag3", - "/mytopic/json?poll=1&ta=tag2,tag3", - "/mytopic/json?poll=1&x-title=a+title", - "/mytopic/json?poll=1&title=a+title", - "/mytopic/json?poll=1&t=a+title", - "/mytopic/json?poll=1&x-message=my+second+message", - "/mytopic/json?poll=1&message=my+second+message", - "/mytopic/json?poll=1&m=my+second+message", - "/mytopic/json?x-poll=1&m=my+second+message", - "/mytopic/json?po=1&m=my+second+message", - } - for _, query := range queriesThatShouldReturnMessageTwo { - response = request(t, s, "GET", query, "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages), "Query failed: "+query) - require.Equal(t, "my second message", messages[0].Message, "Query failed: "+query) - } - - queriesThatShouldReturnNoMessages := []string{ - "/mytopic/json?poll=1&priority=4", - "/mytopic/json?poll=1&tags=tag1,tag2,tag3", - "/mytopic/json?poll=1&title=another+title", - "/mytopic/json?poll=1&message=my+third+message", - "/mytopic/json?poll=1&message=my+third+message", - } - for _, query := range queriesThatShouldReturnNoMessages { - response = request(t, s, "GET", query, "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 0, len(messages), "Query failed: "+query) - } } func TestServer_SubscribeWithQueryFilters(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.KeepaliveInterval = 800 * time.Millisecond - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.KeepaliveInterval = 800 * time.Millisecond + s := newTestServer(t, c) - subscribeResponse := httptest.NewRecorder() - subscribeCancel := subscribe(t, s, "/mytopic/json?tags=zfs-issue", subscribeResponse) + subscribeResponse := httptest.NewRecorder() + subscribeCancel := subscribe(t, s, "/mytopic/json?tags=zfs-issue", subscribeResponse) - response := request(t, s, "PUT", "/mytopic", "my first message", nil) - require.Equal(t, 200, response.Code) - response = request(t, s, "PUT", "/mytopic", "ZFS scrub failed", map[string]string{ - "Tags": "zfs-issue,zfs-scrub", + response := request(t, s, "PUT", "/mytopic", "my first message", nil) + require.Equal(t, 200, response.Code) + response = request(t, s, "PUT", "/mytopic", "ZFS scrub failed", map[string]string{ + "Tags": "zfs-issue,zfs-scrub", + }) + require.Equal(t, 200, response.Code) + + time.Sleep(850 * time.Millisecond) + subscribeCancel() + + messages := toMessages(t, subscribeResponse.Body.String()) + require.Equal(t, 3, len(messages)) + require.Equal(t, openEvent, messages[0].Event) + require.Equal(t, messageEvent, messages[1].Event) + require.Equal(t, "ZFS scrub failed", messages[1].Message) + require.Equal(t, keepaliveEvent, messages[2].Event) }) - require.Equal(t, 200, response.Code) - - time.Sleep(850 * time.Millisecond) - subscribeCancel() - - messages := toMessages(t, subscribeResponse.Body.String()) - require.Equal(t, 3, len(messages)) - require.Equal(t, openEvent, messages[0].Event) - require.Equal(t, messageEvent, messages[1].Event) - require.Equal(t, "ZFS scrub failed", messages[1].Message) - require.Equal(t, keepaliveEvent, messages[2].Event) } func TestServer_Auth_Success_Admin(t *testing.T) { - c := newTestConfigWithAuthFile(t) - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + s := newTestServer(t, c) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) }) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"success":true}`+"\n", response.Body.String()) } func TestServer_Auth_Success_User(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite)) - response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), + response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, response.Code) }) - require.Equal(t, 200, response.Code) } func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite)) - require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite)) + require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite)) - response := request(t, s, "GET", "/mytopic,anothertopic/auth", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), + response := request(t, s, "GET", "/mytopic,anothertopic/auth", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, response.Code) + + response = request(t, s, "GET", "/mytopic,anothertopic,NOT-THIS-ONE/auth", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 403, response.Code) }) - require.Equal(t, 200, response.Code) - - response = request(t, s, "GET", "/mytopic,anothertopic,NOT-THIS-ONE/auth", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 403, response.Code) } func TestServer_Auth_Fail_InvalidPass(t *testing.T) { - c := newTestConfig(t) - c.AuthFile = filepath.Join(t.TempDir(), "user.db") - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "INVALID"), + response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "INVALID"), + }) + require.Equal(t, 401, response.Code) }) - require.Equal(t, 401, response.Code) } func TestServer_Auth_Fail_Unauthorized(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic! + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic! - response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), + response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 403, response.Code) }) - require.Equal(t, 403, response.Code) } func TestServer_Auth_Fail_CannotPublish(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionReadWrite // Open by default - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionReadWrite // Open by default + s := newTestServer(t, c) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) - require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll)) - require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll)) + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) - response := request(t, s, "PUT", "/mytopic", "test", nil) - require.Equal(t, 200, response.Code) + response := request(t, s, "PUT", "/mytopic", "test", nil) + require.Equal(t, 200, response.Code) - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) - response = request(t, s, "PUT", "/announcements", "test", nil) - require.Equal(t, 403, response.Code) // Cannot write as anonymous + response = request(t, s, "PUT", "/announcements", "test", nil) + require.Equal(t, 403, response.Code) // Cannot write as anonymous - response = request(t, s, "PUT", "/announcements", "test", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + response = request(t, s, "PUT", "/announcements", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + + response = request(t, s, "GET", "/announcements/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) // Anonymous read allowed + + response = request(t, s, "GET", "/private/json?poll=1", "", nil) + require.Equal(t, 403, response.Code) // Anonymous read not allowed }) - require.Equal(t, 200, response.Code) - - response = request(t, s, "GET", "/announcements/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) // Anonymous read allowed - - response = request(t, s, "GET", "/private/json?poll=1", "", nil) - require.Equal(t, 403, response.Code) // Anonymous read not allowed } func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.VisitorAuthFailureLimitBurst = 10 - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorAuthFailureLimitBurst = 10 + s := newTestServer(t, c) + + for i := 0; i < 10; i++ { + response := request(t, s, "PUT", "/announcements", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 401, response.Code) + } - for i := 0; i < 10; i++ { response := request(t, s, "PUT", "/announcements", "test", map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), }) - require.Equal(t, 401, response.Code) - } - - response := request(t, s, "PUT", "/announcements", "test", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + require.Equal(t, 429, response.Code) + require.Equal(t, 42909, toHTTPError(t, response.Body.String()).Code) }) - require.Equal(t, 429, response.Code) - require.Equal(t, 42909, toHTTPError(t, response.Body.String()).Code) } func TestServer_Auth_ViaQuery(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, c) - require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin, false)) - u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass")))) - response := request(t, s, "GET", u, "", nil) - require.Equal(t, 200, response.Code) + u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass")))) + response := request(t, s, "GET", u, "", nil) + require.Equal(t, 200, response.Code) - u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "WRONNNGGGG")))) - response = request(t, s, "GET", u, "", nil) - require.Equal(t, 401, response.Code) + u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "WRONNNGGGG")))) + response = request(t, s, "GET", u, "", nil) + require.Equal(t, 401, response.Code) + }) } func TestServer_Auth_NonBasicHeader(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithAuthFile(t)) - response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ - "Authorization": "WebPush not-supported", - }) - require.Equal(t, 200, response.Code) + response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": "WebPush not-supported", + }) + require.Equal(t, 200, response.Code) - response = request(t, s, "PUT", "/mytopic", "test", map[string]string{ - "Authorization": "Bearer supported", - }) - require.Equal(t, 401, response.Code) + response = request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": "Bearer supported", + }) + require.Equal(t, 401, response.Code) - response = request(t, s, "PUT", "/mytopic", "test", map[string]string{ - "Authorization": "basic supported", + response = request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": "basic supported", + }) + require.Equal(t, 401, response.Code) }) - require.Equal(t, 401, response.Code) } func TestServer_StatsResetter(t *testing.T) { - t.Parallel() - // This tests the stats resetter for - // - an anonymous user - // - a user without a tier (treated like the same as the anonymous user) - // - a user with a tier + forEachBackend(t, func(t *testing.T) { + t.Parallel() + // This tests the stats resetter for + // - an anonymous user + // - a user without a tier (treated like the same as the anonymous user) + // - a user with a tier - c := newTestConfigWithAuthFile(t) - c.VisitorStatsResetTime = time.Now().Add(2 * time.Second) - s := newTestServer(t, c) - go s.runStatsResetter() + c := newTestConfigWithAuthFile(t) + c.VisitorStatsResetTime = time.Now().Add(2 * time.Second) + s := newTestServer(t, c) + go s.runStatsResetter() - // Create user with tier (tieruser) and user without tier (phil) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "test", - MessageLimit: 5, - MessageExpiryDuration: -5 * time.Second, // Second, what a hack! - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.AddUser("tieruser", "tieruser", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("tieruser", "test")) + // Create user with tier (tieruser) and user without tier (phil) + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 5, + MessageExpiryDuration: -5 * time.Second, // Second, what a hack! + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.AddUser("tieruser", "tieruser", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("tieruser", "test")) - // Send an anonymous message - response := request(t, s, "PUT", "/mytopic", "test", nil) - require.Equal(t, 200, response.Code) + // Send an anonymous message + response := request(t, s, "PUT", "/mytopic", "test", nil) + require.Equal(t, 200, response.Code) - // Send messages from user without tier (phil) - for i := 0; i < 5; i++ { - response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + // Send messages from user without tier (phil) + for i := 0; i < 5; i++ { + response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + } + + // Send messages from user with tier + for i := 0; i < 2; i++ { + response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("tieruser", "tieruser"), + }) + require.Equal(t, 200, response.Code) + } + + // User stats show 6 messages (for user without tier) + response = request(t, s, "GET", "/v1/account", "", map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, response.Code) - } + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(6), account.Stats.Messages) - // Send messages from user with tier - for i := 0; i < 2; i++ { - response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + // User stats show 6 messages (for anonymous visitor) + response = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(6), account.Stats.Messages) + + // User stats show 2 messages (for user with tier) + response = request(t, s, "GET", "/v1/account", "", map[string]string{ "Authorization": util.BasicAuth("tieruser", "tieruser"), }) require.Equal(t, 200, response.Code) - } + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(2), account.Stats.Messages) - // User stats show 6 messages (for user without tier) - response = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, int64(6), account.Stats.Messages) + // Wait for stats resetter to run + waitFor(t, func() bool { + response = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + return account.Stats.Messages == 0 + }) - // User stats show 6 messages (for anonymous visitor) - response = request(t, s, "GET", "/v1/account", "", nil) - require.Equal(t, 200, response.Code) - account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, int64(6), account.Stats.Messages) - - // User stats show 2 messages (for user with tier) - response = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("tieruser", "tieruser"), - }) - require.Equal(t, 200, response.Code) - account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, int64(2), account.Stats.Messages) - - // Wait for stats resetter to run - waitFor(t, func() bool { + // User stats show 0 messages now! response = request(t, s, "GET", "/v1/account", "", map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, response.Code) account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) require.Nil(t, err) - return account.Stats.Messages == 0 - }) + require.Equal(t, int64(0), account.Stats.Messages) - // User stats show 0 messages now! - response = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, int64(0), account.Stats.Messages) + // Since this is a user without a tier, the anonymous user should have the same stats + response = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(0), account.Stats.Messages) - // Since this is a user without a tier, the anonymous user should have the same stats - response = request(t, s, "GET", "/v1/account", "", nil) - require.Equal(t, 200, response.Code) - account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, int64(0), account.Stats.Messages) - - // User stats show 0 messages (for user with tier) - response = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("tieruser", "tieruser"), + // User stats show 0 messages (for user with tier) + response = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("tieruser", "tieruser"), + }) + require.Equal(t, 200, response.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(0), account.Stats.Messages) }) - require.Equal(t, 200, response.Code) - account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, int64(0), account.Stats.Messages) } func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { - // This tests that the messageLimiter (the only fixed limiter) and the emailsLimiter (token bucket) - // is reset by the stats resetter + forEachBackend(t, func(t *testing.T) { + // This tests that the messageLimiter (the only fixed limiter) and the emailsLimiter (token bucket) + // is reset by the stats resetter - c := newTestConfigWithAuthFile(t) - s := newTestServer(t, c) - s.smtpSender = &testMailer{} + c := newTestConfigWithAuthFile(t) + s := newTestServer(t, c) + s.smtpSender = &testMailer{} - // Publish some messages, and check stats - for i := 0; i < 3; i++ { - response := request(t, s, "PUT", "/mytopic", "test", nil) + // Publish some messages, and check stats + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/mytopic", "test", nil) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ + "Email": "test@email.com", + }) require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ - "Email": "test@email.com", + + rr := request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, int64(4), account.Stats.Messages) + require.Equal(t, int64(1), account.Stats.Emails) + v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil) + require.Equal(t, int64(4), v.Stats().Messages) + require.Equal(t, int64(4), v.messagesLimiter.Value()) + require.Equal(t, int64(1), v.Stats().Emails) + require.Equal(t, int64(1), v.emailsLimiter.Value()) + + // Reset stats and check again + s.resetStats() + rr = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, int64(0), account.Stats.Messages) + require.Equal(t, int64(0), account.Stats.Emails) + v = s.visitor(netip.MustParseAddr("9.9.9.9"), nil) + require.Equal(t, int64(0), v.Stats().Messages) + require.Equal(t, int64(0), v.messagesLimiter.Value()) + require.Equal(t, int64(0), v.Stats().Emails) + require.Equal(t, int64(0), v.emailsLimiter.Value()) }) - require.Equal(t, 200, response.Code) - - rr := request(t, s, "GET", "/v1/account", "", nil) - require.Equal(t, 200, rr.Code) - account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Nil(t, err) - require.Equal(t, int64(4), account.Stats.Messages) - require.Equal(t, int64(1), account.Stats.Emails) - v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil) - require.Equal(t, int64(4), v.Stats().Messages) - require.Equal(t, int64(4), v.messagesLimiter.Value()) - require.Equal(t, int64(1), v.Stats().Emails) - require.Equal(t, int64(1), v.emailsLimiter.Value()) - - // Reset stats and check again - s.resetStats() - rr = request(t, s, "GET", "/v1/account", "", nil) - require.Equal(t, 200, rr.Code) - account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Nil(t, err) - require.Equal(t, int64(0), account.Stats.Messages) - require.Equal(t, int64(0), account.Stats.Emails) - v = s.visitor(netip.MustParseAddr("9.9.9.9"), nil) - require.Equal(t, int64(0), v.Stats().Messages) - require.Equal(t, int64(0), v.messagesLimiter.Value()) - require.Equal(t, int64(0), v.Stats().Emails) - require.Equal(t, int64(0), v.emailsLimiter.Value()) } func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) { - t.Parallel() + forEachBackend(t, func(t *testing.T) { + t.Parallel() - // This tests that the daily message quota is prefilled originally from the database, - // if the visitor is unknown + // This tests that the daily message quota is prefilled originally from the database, + // if the visitor is unknown - c := newTestConfigWithAuthFile(t) - c.AuthStatsQueueWriterInterval = 100 * time.Millisecond - s := newTestServer(t, c) + c := newTestConfigWithAuthFile(t) + c.AuthStatsQueueWriterInterval = 100 * time.Millisecond + s := newTestServer(t, c) - // Create user, and update it with some message and email stats - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "test", - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "test")) + // Create user, and update it with some message and email stats + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) - u, err := s.userManager.User("phil") - require.Nil(t, err) - s.userManager.EnqueueUserStats(u.ID, &user.Stats{ - Messages: 123456, - Emails: 999, + u, err := s.userManager.User("phil") + require.Nil(t, err) + s.userManager.EnqueueUserStats(u.ID, &user.Stats{ + Messages: 123456, + Emails: 999, + }) + time.Sleep(400 * time.Millisecond) + + // Get account and verify stats are read from the DB, and that the visitor also has these stats + rr := request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, int64(123456), account.Stats.Messages) + require.Equal(t, int64(999), account.Stats.Emails) + v := s.visitor(netip.MustParseAddr("9.9.9.9"), u) + require.Equal(t, int64(123456), v.Stats().Messages) + require.Equal(t, int64(123456), v.messagesLimiter.Value()) + require.Equal(t, int64(999), v.Stats().Emails) + require.Equal(t, int64(999), v.emailsLimiter.Value()) }) - time.Sleep(400 * time.Millisecond) - - // Get account and verify stats are read from the DB, and that the visitor also has these stats - rr := request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Nil(t, err) - require.Equal(t, int64(123456), account.Stats.Messages) - require.Equal(t, int64(999), account.Stats.Emails) - v := s.visitor(netip.MustParseAddr("9.9.9.9"), u) - require.Equal(t, int64(123456), v.Stats().Messages) - require.Equal(t, int64(123456), v.messagesLimiter.Value()) - require.Equal(t, int64(999), v.Stats().Emails) - require.Equal(t, int64(999), v.emailsLimiter.Value()) } type testMailer struct { @@ -1229,561 +1339,630 @@ func (t *testMailer) Count() int { } func TestServer_PublishTooManyRequests_Defaults(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - for i := 0; i < 60; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) - require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/mytopic", "message", nil) - require.Equal(t, 429, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + for i := 0; i < 60; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "message", nil) + require.Equal(t, 429, response.Code) + }) } func TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - overrideRemoteAddr1 := func(r *http.Request) { - r.RemoteAddr = "[2001:db8:9999:8888:1::1]:1234" - } - overrideRemoteAddr2 := func(r *http.Request) { - r.RemoteAddr = "[2001:db8:9999:8888:2::1]:1234" // Same /64 - } - for i := 0; i < 30; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1) - require.Equal(t, 200, response.Code) - } - for i := 0; i < 30; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2) - require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1) - require.Equal(t, 429, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + overrideRemoteAddr1 := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999:8888:1::1]:1234" + } + overrideRemoteAddr2 := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999:8888:2::1]:1234" // Same /64 + } + for i := 0; i < 30; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1) + require.Equal(t, 200, response.Code) + } + for i := 0; i < 30; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1) + require.Equal(t, 429, response.Code) + }) } func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 6 - c.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes - s := newTestServer(t, c) - overrideRemoteAddr1 := func(r *http.Request) { - r.RemoteAddr = "[2001:db8:9999::1]:1234" - } - overrideRemoteAddr2 := func(r *http.Request) { - r.RemoteAddr = "[2001:db8:9999::2]:1234" // Same /48 - } - for i := 0; i < 3; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1) - require.Equal(t, 200, response.Code) - } - for i := 0; i < 3; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2) - require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1) - require.Equal(t, 429, response.Code) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 6 + c.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes + s := newTestServer(t, c) + overrideRemoteAddr1 := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999::1]:1234" + } + overrideRemoteAddr2 := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999::2]:1234" // Same /48 + } + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1) + require.Equal(t, 200, response.Code) + } + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1) + require.Equal(t, 429, response.Code) + }) } func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() - s := newTestServer(t, c) - for i := 0; i < 5; i++ { // > 3 - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) - require.Equal(t, 200, response.Code) - } + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() + s := newTestServer(t, c) + for i := 0; i < 5; i++ { // > 3 + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) + require.Equal(t, 200, response.Code) + } + }) } func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")} - s := newTestServer(t, c) - overrideRemoteAddr := func(r *http.Request) { - r.RemoteAddr = "[2001:db8:9999::1]:1234" - } - for i := 0; i < 5; i++ { // > 3 - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr) - require.Equal(t, 200, response.Code) - } + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")} + s := newTestServer(t, c) + overrideRemoteAddr := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999::1]:1234" + } + for i := 0; i < 5; i++ { // > 3 + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr) + require.Equal(t, 200, response.Code) + } + }) } func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 10 - c.VisitorMessageDailyLimit = 4 - c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() - s := newTestServer(t, c) - for i := 0; i < 8; i++ { // 4 - response := request(t, s, "PUT", "/mytopic", "message", nil) - require.Equal(t, 200, response.Code) - } + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 10 + c.VisitorMessageDailyLimit = 4 + c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() + s := newTestServer(t, c) + for i := 0; i < 8; i++ { // 4 + response := request(t, s, "PUT", "/mytopic", "message", nil) + require.Equal(t, 200, response.Code) + } + }) } func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 60 - c.VisitorRequestLimitReplenish = time.Second - s := newTestServer(t, c) - for i := 0; i < 60; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) - require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/mytopic", "message", nil) - require.Equal(t, 429, response.Code) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 60 + c.VisitorRequestLimitReplenish = time.Second + s := newTestServer(t, c) + for i := 0; i < 60; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "message", nil) + require.Equal(t, 429, response.Code) - time.Sleep(1020 * time.Millisecond) - response = request(t, s, "PUT", "/mytopic", "message", nil) - require.Equal(t, 200, response.Code) + time.Sleep(1020 * time.Millisecond) + response = request(t, s, "PUT", "/mytopic", "message", nil) + require.Equal(t, 200, response.Code) + }) } func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - s.smtpSender = &testMailer{} - for i := 0; i < 16; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + s.smtpSender = &testMailer{} + for i := 0; i < 16; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ + "E-Mail": "test@example.com", + }) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{ "E-Mail": "test@example.com", }) - require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{ - "E-Mail": "test@example.com", + require.Equal(t, 429, response.Code) }) - require.Equal(t, 429, response.Code) } func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.VisitorEmailLimitReplenish = 500 * time.Millisecond - s := newTestServer(t, c) - s.smtpSender = &testMailer{} - for i := 0; i < 16; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.VisitorEmailLimitReplenish = 500 * time.Millisecond + s := newTestServer(t, c) + s.smtpSender = &testMailer{} + for i := 0; i < 16; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ + "E-Mail": "test@example.com", + }) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{ + "E-Mail": "test@example.com", + }) + require.Equal(t, 429, response.Code) + + time.Sleep(510 * time.Millisecond) + response = request(t, s, "PUT", "/mytopic", "this should be okay again too many", map[string]string{ "E-Mail": "test@example.com", }) require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{ - "E-Mail": "test@example.com", - }) - require.Equal(t, 429, response.Code) - time.Sleep(510 * time.Millisecond) - response = request(t, s, "PUT", "/mytopic", "this should be okay again too many", map[string]string{ - "E-Mail": "test@example.com", + response = request(t, s, "PUT", "/mytopic", "and bad again", map[string]string{ + "E-Mail": "test@example.com", + }) + require.Equal(t, 429, response.Code) }) - require.Equal(t, 200, response.Code) - - response = request(t, s, "PUT", "/mytopic", "and bad again", map[string]string{ - "E-Mail": "test@example.com", - }) - require.Equal(t, 429, response.Code) } func TestServer_PublishDelayedEmail_Fail(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - s.smtpSender = &testMailer{} - response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ - "E-Mail": "test@example.com", - "Delay": "20 min", + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + s.smtpSender = &testMailer{} + response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ + "E-Mail": "test@example.com", + "Delay": "20 min", + }) + require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code) }) - require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishDelayedCall_Fail(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ - "Call": "yes", - "Delay": "20 min", + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ + "Call": "yes", + "Delay": "20 min", + }) + require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code) }) - require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ - "E-Mail": "test@example.com", + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ + "E-Mail": "test@example.com", + }) + require.Equal(t, 400, response.Code) }) - require.Equal(t, 400, response.Code) } func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - defer s.messageCache.Close() + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + defer s.messageCache.Close() - subFn := func(v *visitor, msg *model.Message) error { - return nil - } - - // Publish and check last access - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "Cache": "no", - }) - require.Equal(t, 200, response.Code) - waitFor(t, func() bool { - s.mu.Lock() - tp, exists := s.topics["mytopic"] - s.mu.Unlock() - if !exists { - return false + subFn := func(v *visitor, msg *model.Message) error { + return nil } - // .lastAccess set in t.Publish() -> t.Keepalive() in Goroutine - tp.mu.RLock() - defer tp.mu.RUnlock() - return tp.lastAccess.Unix() >= time.Now().Unix()-2 && - tp.lastAccess.Unix() <= time.Now().Unix()+2 + + // Publish and check last access + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Cache": "no", + }) + require.Equal(t, 200, response.Code) + waitFor(t, func() bool { + s.mu.Lock() + tp, exists := s.topics["mytopic"] + s.mu.Unlock() + if !exists { + return false + } + // .lastAccess set in t.Publish() -> t.Keepalive() in Goroutine + tp.mu.RLock() + defer tp.mu.RUnlock() + return tp.lastAccess.Unix() >= time.Now().Unix()-2 && + tp.lastAccess.Unix() <= time.Now().Unix()+2 + }) + + // Hack! + time.Sleep(time.Second) + + // Topic won't get pruned + s.execManager() + require.NotNil(t, s.topics["mytopic"]) + + // Fudge with last access, but subscribe, and see that it won't get pruned (because of subscriber) + subID := s.topics["mytopic"].Subscribe(subFn, "", func() {}) + s.topics["mytopic"].mu.Lock() + s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) + s.topics["mytopic"].mu.Unlock() + s.execManager() + require.NotNil(t, s.topics["mytopic"]) + + // It'll finally get pruned now that there are no subscribers and last access is 17 hours ago + s.topics["mytopic"].Unsubscribe(subID) + s.execManager() + require.Nil(t, s.topics["mytopic"]) }) - - // Hack! - time.Sleep(time.Second) - - // Topic won't get pruned - s.execManager() - require.NotNil(t, s.topics["mytopic"]) - - // Fudge with last access, but subscribe, and see that it won't get pruned (because of subscriber) - subID := s.topics["mytopic"].Subscribe(subFn, "", func() {}) - s.topics["mytopic"].mu.Lock() - s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) - s.topics["mytopic"].mu.Unlock() - s.execManager() - require.NotNil(t, s.topics["mytopic"]) - - // It'll finally get pruned now that there are no subscribers and last access is 17 hours ago - s.topics["mytopic"].Unsubscribe(subID) - s.execManager() - require.Nil(t, s.topics["mytopic"]) } func TestServer_TopicKeepaliveOnPoll(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Create topic by polling once - response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) + // Create topic by polling once + response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) - // Mess with last access time - s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) + // Mess with last access time + s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) - // Poll again and check keepalive time - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - require.True(t, s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2) - require.True(t, s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2) + // Poll again and check keepalive time + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + require.True(t, s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2) + require.True(t, s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2) + }) } func TestServer_UnifiedPushDiscovery(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "GET", "/mytopic?up=1", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String()) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "GET", "/mytopic?up=1", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String()) + }) } func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) { - b := make([]byte, 12) // Max length - _, err := rand.Read(b) - require.Nil(t, err) + forEachBackend(t, func(t *testing.T) { + b := make([]byte, 12) // Max length + _, err := rand.Read(b) + require.Nil(t, err) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t)) - // Register a UnifiedPush subscriber - response := request(t, s, "GET", "/up123456789012/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) + // Register a UnifiedPush subscriber + response := request(t, s, "GET", "/up123456789012/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) - // Publish message to topic - response = request(t, s, "PUT", "/up123456789012?up=1", string(b), nil) - require.Equal(t, 200, response.Code) + // Publish message to topic + response = request(t, s, "PUT", "/up123456789012?up=1", string(b), nil) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "base64", m.Encoding) - b2, err := base64.StdEncoding.DecodeString(m.Message) - require.Nil(t, err) - require.Equal(t, b, b2) + m := toMessage(t, response.Body.String()) + require.Equal(t, "base64", m.Encoding) + b2, err := base64.StdEncoding.DecodeString(m.Message) + require.Nil(t, err) + require.Equal(t, b, b2) - // Retrieve and check published message - response = request(t, s, "GET", "/up123456789012/json?poll=1", string(b), nil) - require.Equal(t, 200, response.Code) - m = toMessage(t, response.Body.String()) - require.Equal(t, "base64", m.Encoding) - b2, err = base64.StdEncoding.DecodeString(m.Message) - require.Nil(t, err) - require.Equal(t, b, b2) + // Retrieve and check published message + response = request(t, s, "GET", "/up123456789012/json?poll=1", string(b), nil) + require.Equal(t, 200, response.Code) + m = toMessage(t, response.Body.String()) + require.Equal(t, "base64", m.Encoding) + b2, err = base64.StdEncoding.DecodeString(m.Message) + require.Nil(t, err) + require.Equal(t, b, b2) + }) } func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) { - b := make([]byte, 5000) // Longer than max length - _, err := rand.Read(b) - require.Nil(t, err) + forEachBackend(t, func(t *testing.T) { + b := make([]byte, 5000) // Longer than max length + _, err := rand.Read(b) + require.Nil(t, err) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t)) - // Register a UnifiedPush subscriber - response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) + // Register a UnifiedPush subscriber + response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) - // Publish message to topic - response = request(t, s, "PUT", "/mytopic?up=1", string(b), nil) - require.Equal(t, 200, response.Code) + // Publish message to topic + response = request(t, s, "PUT", "/mytopic?up=1", string(b), nil) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "base64", m.Encoding) - b2, err := base64.StdEncoding.DecodeString(m.Message) - require.Nil(t, err) - require.Equal(t, 4096, len(b2)) - require.Equal(t, b[:4096], b2) + m := toMessage(t, response.Body.String()) + require.Equal(t, "base64", m.Encoding) + b2, err := base64.StdEncoding.DecodeString(m.Message) + require.Nil(t, err) + require.Equal(t, 4096, len(b2)) + require.Equal(t, b[:4096], b2) + }) } func TestServer_PublishUnifiedPushText(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - // Register a UnifiedPush subscriber - response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) + // Register a UnifiedPush subscriber + response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) - // Publish UnifiedPush text message - response = request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil) - require.Equal(t, 200, response.Code) + // Publish UnifiedPush text message + response = request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "", m.Encoding) - require.Equal(t, "this is a unifiedpush text message", m.Message) + m := toMessage(t, response.Body.String()) + require.Equal(t, "", m.Encoding) + require.Equal(t, "this is a unifiedpush text message", m.Message) + }) } func TestServer_MatrixGateway_Discovery_Success(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String()) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String()) + }) } func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) { - c := newTestConfig(t) - c.BaseURL = "" - s := newTestServer(t, c) - response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) - require.Equal(t, 500, response.Code) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 50003, err.Code) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BaseURL = "" + s := newTestServer(t, c) + response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) + require.Equal(t, 500, response.Code) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 50003, err.Code) + }) } func TestServer_MatrixGateway_Push_Success(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) + response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) - notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` - response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, notification, m.Message) + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, notification, m.Message) + }) } func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) { - c := newTestConfig(t) - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 507, response.Code) - require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 507, response.Code) + require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing.T) { - c := newTestConfig(t) - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` - // No success if no rate visitor set (this also creates the topic in memory) - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 507, response.Code) - require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code) - require.Nil(t, s.topics["mytopic"].rateVisitor) + // No success if no rate visitor set (this also creates the topic in memory) + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 507, response.Code) + require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code) + require.Nil(t, s.topics["mytopic"].rateVisitor) - // Fake: This topic has been around for 13 hours without a rate visitor - s.topics["mytopic"].lastAccess = time.Now().Add(-13 * time.Hour) + // Fake: This topic has been around for 13 hours without a rate visitor + s.topics["mytopic"].lastAccess = time.Now().Add(-13 * time.Hour) - // Same request should now return HTTP 200 with a rejected pushkey - response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"rejected":["http://127.0.0.1:12345/mytopic?up=1"]}`, strings.TrimSpace(response.Body.String())) + // Same request should now return HTTP 200 with a rejected pushkey + response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":["http://127.0.0.1:12345/mytopic?up=1"]}`, strings.TrimSpace(response.Body.String())) - // Slightly unrelated: Test that topic is pruned after 16 hours - s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) - s.execManager() - require.Nil(t, s.topics["mytopic"]) + // Slightly unrelated: Test that topic is pruned after 16 hours + s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) + s.execManager() + require.Nil(t, s.topics["mytopic"]) + }) } func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}` - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"rejected":["http://wrong-base-url.com/mytopic?up=1"]}`+"\n", response.Body.String()) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":["http://wrong-base-url.com/mytopic?up=1"]}`+"\n", response.Body.String()) - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, "", response.Body.String()) // Empty! + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "", response.Body.String()) // Empty! + }) } func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - notification := `{"message":"this is not really a Matrix message"}` - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 400, response.Code) - require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + notification := `{"message":"this is not really a Matrix message"}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 400, response.Code) + require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code) - notification = `this isn't even JSON'` - response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 400, response.Code) - require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code) + notification = `this isn't even JSON'` + response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 400, response.Code) + require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) { - c := newTestConfig(t) - c.BaseURL = "" - s := newTestServer(t, c) - notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 500, response.Code) - require.Equal(t, 50003, toHTTPError(t, response.Body.String()).Code) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BaseURL = "" + s := newTestServer(t, c) + notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 500, response.Code) + require.Equal(t, 50003, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_PublishActions_AndPoll(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{ - "Actions": "view, Open portal, https://home.nest.com/; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65", - }) - require.Equal(t, 200, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{ + "Actions": "view, Open portal, https://home.nest.com/; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65", + }) + require.Equal(t, 200, response.Code) - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, 2, len(m.Actions)) - require.Equal(t, "view", m.Actions[0].Action) - require.Equal(t, "Open portal", m.Actions[0].Label) - require.Equal(t, "https://home.nest.com/", m.Actions[0].URL) - require.Equal(t, "http", m.Actions[1].Action) - require.Equal(t, "Turn down", m.Actions[1].Label) - require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL) - require.Equal(t, "target_temp_f=65", m.Actions[1].Body) + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, 2, len(m.Actions)) + require.Equal(t, "view", m.Actions[0].Action) + require.Equal(t, "Open portal", m.Actions[0].Label) + require.Equal(t, "https://home.nest.com/", m.Actions[0].URL) + require.Equal(t, "http", m.Actions[1].Action) + require.Equal(t, "Turn down", m.Actions[1].Label) + require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL) + require.Equal(t, "target_temp_f=65", m.Actions[1].Body) + }) } func TestServer_PublishMarkdown(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ - "Content-Type": "text/markdown", - }) - require.Equal(t, 200, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ + "Content-Type": "text/markdown", + }) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "**make this bold**", m.Message) - require.Equal(t, "text/markdown", m.ContentType) + m := toMessage(t, response.Body.String()) + require.Equal(t, "**make this bold**", m.Message) + require.Equal(t, "text/markdown", m.ContentType) + }) } func TestServer_PublishMarkdown_QueryParam(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?md=1", "**make this bold**", nil) - require.Equal(t, 200, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?md=1", "**make this bold**", nil) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "**make this bold**", m.Message) - require.Equal(t, "text/markdown", m.ContentType) + m := toMessage(t, response.Body.String()) + require.Equal(t, "**make this bold**", m.Message) + require.Equal(t, "text/markdown", m.ContentType) + }) } func TestServer_PublishMarkdown_NotMarkdown(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ - "Content-Type": "not-markdown", - }) - require.Equal(t, 200, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ + "Content-Type": "not-markdown", + }) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "", m.ContentType) + m := toMessage(t, response.Body.String()) + require.Equal(t, "", m.ContentType) + }) } func TestServer_PublishAsJSON(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` + - `"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` + - `"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}` - response := request(t, s, "PUT", "/", body, nil) - require.Equal(t, 200, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` + + `"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` + + `"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "mytopic", m.Topic) - require.Equal(t, "A message", m.Message) - require.Equal(t, "a title\nwith lines", m.Title) - require.Equal(t, []string{"tag1", "tag 2"}, m.Tags) - require.Equal(t, "http://google.com", m.Attachment.URL) - require.Equal(t, "google.pdf", m.Attachment.Name) - require.Equal(t, "http://ntfy.sh", m.Click) - require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon) - require.Equal(t, "", m.ContentType) + m := toMessage(t, response.Body.String()) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "A message", m.Message) + require.Equal(t, "a title\nwith lines", m.Title) + require.Equal(t, []string{"tag1", "tag 2"}, m.Tags) + require.Equal(t, "http://google.com", m.Attachment.URL) + require.Equal(t, "google.pdf", m.Attachment.Name) + require.Equal(t, "http://ntfy.sh", m.Click) + require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon) + require.Equal(t, "", m.ContentType) - require.Equal(t, 4, m.Priority) - require.True(t, m.Time > time.Now().Unix()+29*60) - require.True(t, m.Time < time.Now().Unix()+31*60) + require.Equal(t, 4, m.Priority) + require.True(t, m.Time > time.Now().Unix()+29*60) + require.True(t, m.Time < time.Now().Unix()+31*60) + }) } func TestServer_PublishAsJSON_Markdown(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - body := `{"topic":"mytopic","message":"**This is bold**","markdown":true}` - response := request(t, s, "PUT", "/", body, nil) - require.Equal(t, 200, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic":"mytopic","message":"**This is bold**","markdown":true}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "mytopic", m.Topic) - require.Equal(t, "**This is bold**", m.Message) - require.Equal(t, "text/markdown", m.ContentType) + m := toMessage(t, response.Body.String()) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "**This is bold**", m.Message) + require.Equal(t, "text/markdown", m.ContentType) + }) } func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) { - // Publishing as JSON follows a different path. This ensures that rate - // limiting works for this endpoint as well - c := newTestConfig(t) - c.VisitorMessageDailyLimit = 3 - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + // Publishing as JSON follows a different path. This ensures that rate + // limiting works for this endpoint as well + c := newTestConfig(t) + c.VisitorMessageDailyLimit = 3 + s := newTestServer(t, c) - for i := 0; i < 3; i++ { + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil) + require.Equal(t, 200, response.Code) + } response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil) - require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil) - require.Equal(t, 429, response.Code) - require.Equal(t, 42908, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 429, response.Code) + require.Equal(t, 42908, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_PublishAsJSON_WithEmail(t *testing.T) { - t.Parallel() - mailer := &testMailer{} - s := newTestServer(t, newTestConfig(t)) - s.smtpSender = mailer - body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` - response := request(t, s, "PUT", "/", body, nil) - require.Equal(t, 200, response.Code) - time.Sleep(100 * time.Millisecond) // E-Mail publishing happens in a Go routine + forEachBackend(t, func(t *testing.T) { + t.Parallel() + mailer := &testMailer{} + s := newTestServer(t, newTestConfig(t)) + s.smtpSender = mailer + body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) + time.Sleep(100 * time.Millisecond) // E-Mail publishing happens in a Go routine - m := toMessage(t, response.Body.String()) - require.Equal(t, "mytopic", m.Topic) - require.Equal(t, "A message", m.Message) - require.Equal(t, 1, mailer.Count()) + m := toMessage(t, response.Body.String()) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "A message", m.Message) + require.Equal(t, 1, mailer.Count()) + }) } func TestServer_PublishAsJSON_WithActions(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - body := `{ + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{ "topic":"mytopic", "message":"A message", "actions": [ @@ -1800,1225 +1979,1333 @@ func TestServer_PublishAsJSON_WithActions(t *testing.T) { } ] }` - response := request(t, s, "POST", "/", body, nil) - require.Equal(t, 200, response.Code) + response := request(t, s, "POST", "/", body, nil) + require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "mytopic", m.Topic) - require.Equal(t, "A message", m.Message) - require.Equal(t, 2, len(m.Actions)) - require.Equal(t, "view", m.Actions[0].Action) - require.Equal(t, "Open portal", m.Actions[0].Label) - require.Equal(t, "https://home.nest.com/", m.Actions[0].URL) - require.Equal(t, "http", m.Actions[1].Action) - require.Equal(t, "Turn down", m.Actions[1].Label) - require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL) - require.Equal(t, "target_temp_f=65", m.Actions[1].Body) + m := toMessage(t, response.Body.String()) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "A message", m.Message) + require.Equal(t, 2, len(m.Actions)) + require.Equal(t, "view", m.Actions[0].Action) + require.Equal(t, "Open portal", m.Actions[0].Label) + require.Equal(t, "https://home.nest.com/", m.Actions[0].URL) + require.Equal(t, "http", m.Actions[1].Action) + require.Equal(t, "Turn down", m.Actions[1].Label) + require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL) + require.Equal(t, "target_temp_f=65", m.Actions[1].Body) + }) } func TestServer_PublishAsJSON_NoCache(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - body := `{"topic":"mytopic","message": "this message is not cached","cache":"no"}` - response := request(t, s, "PUT", "/", body, nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, "this message is not cached", msg.Message) - require.Equal(t, int64(0), msg.Expires) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic":"mytopic","message": "this message is not cached","cache":"no"}` + response := request(t, s, "PUT", "/", body, nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, "this message is not cached", msg.Message) + require.Equal(t, int64(0), msg.Expires) - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Empty(t, messages) + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Empty(t, messages) + }) } func TestServer_PublishAsJSON_WithoutFirebase(t *testing.T) { - sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) - s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + forEachBackend(t, func(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) - body := `{"topic":"mytopic","message": "my first message","firebase":"no"}` - response := request(t, s, "PUT", "/", body, nil) - msg1 := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg1.ID) - require.Equal(t, "my first message", msg1.Message) + body := `{"topic":"mytopic","message": "my first message","firebase":"no"}` + response := request(t, s, "PUT", "/", body, nil) + msg1 := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg1.ID) + require.Equal(t, "my first message", msg1.Message) - time.Sleep(100 * time.Millisecond) // Firebase publishing happens - require.Equal(t, 0, len(sender.Messages())) + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 0, len(sender.Messages())) + }) } func TestServer_PublishAsJSON_Invalid(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - body := `{"topic":"mytopic",INVALID` - response := request(t, s, "PUT", "/", body, nil) - require.Equal(t, 400, response.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic":"mytopic",INVALID` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 400, response.Code) + }) } func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) { - c := newTestConfigWithAuthFile(t) - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + s := newTestServer(t, c) - // Create tier with certain limits - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "test", - MessageLimit: 5, - MessageExpiryDuration: -5 * time.Second, // Second, what a hack! - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "test")) + // Create tier with certain limits + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 5, + MessageExpiryDuration: -5 * time.Second, // Second, what a hack! + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) - // Publish to reach message limit - for i := 0; i < 5; i++ { - response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("this is message %d", i+1), map[string]string{ + // Publish to reach message limit + for i := 0; i < 5; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("this is message %d", i+1), map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.True(t, msg.Expires < time.Now().Unix()+5) + } + response := request(t, s, "PUT", "/mytopic", "this is too much", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 429, response.Code) + + // Run pruning and see if they are gone + s.execManager() + response = request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, response.Code) - msg := toMessage(t, response.Body.String()) - require.True(t, msg.Expires < time.Now().Unix()+5) - } - response := request(t, s, "PUT", "/mytopic", "this is too much", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + require.Empty(t, response.Body) }) - require.Equal(t, 429, response.Code) - - // Run pruning and see if they are gone - s.execManager() - response = request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - require.Empty(t, response.Body) } func TestServer_PublishAttachment(t *testing.T) { - content := "text file!" + util.RandomString(4990) // > 4096 - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", content, nil) - msg := toMessage(t, response.Body.String()) - require.Equal(t, "attachment.txt", msg.Attachment.Name) - require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) - require.Equal(t, int64(5000), msg.Attachment.Size) - require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours - require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + forEachBackend(t, func(t *testing.T) { + content := "text file!" + util.RandomString(4990) // > 4096 + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", content, nil) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "attachment.txt", msg.Attachment.Name) + require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) + require.Equal(t, int64(5000), msg.Attachment.Size) + require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) - // GET - path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") - response = request(t, s, "GET", path, "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, "5000", response.Header().Get("Content-Length")) - require.Equal(t, content, response.Body.String()) + // GET + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "5000", response.Header().Get("Content-Length")) + require.Equal(t, content, response.Body.String()) - // HEAD - response = request(t, s, "HEAD", path, "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, "5000", response.Header().Get("Content-Length")) - require.Equal(t, "", response.Body.String()) + // HEAD + response = request(t, s, "HEAD", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "5000", response.Header().Get("Content-Length")) + require.Equal(t, "", response.Body.String()) - // Slightly unrelated cross-test: make sure we add an owner for internal attachments - size, err := s.messageCache.AttachmentBytesUsedBySender("9.9.9.9") // See request() - require.Nil(t, err) - require.Equal(t, int64(5000), size) + // Slightly unrelated cross-test: make sure we add an owner for internal attachments + size, err := s.messageCache.AttachmentBytesUsedBySender("9.9.9.9") // See request() + require.Nil(t, err) + require.Equal(t, int64(5000), size) + }) } func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { - c := newTestConfig(t) - c.BehindProxy = true - s := newTestServer(t, c) - content := "this is an ATTACHMENT" - response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, map[string]string{ - "X-Forwarded-For": "1.2.3.4", + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + s := newTestServer(t, c) + content := "this is an ATTACHMENT" + response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, map[string]string{ + "X-Forwarded-For": "1.2.3.4", + }) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "myfile.txt", msg.Attachment.Name) + require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) + require.Equal(t, int64(21), msg.Attachment.Size) + require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "21", response.Header().Get("Content-Length")) + require.Equal(t, content, response.Body.String()) + + // Slightly unrelated cross-test: make sure we add an owner for internal attachments + size, err := s.messageCache.AttachmentBytesUsedBySender("1.2.3.4") + require.Nil(t, err) + require.Equal(t, int64(21), size) }) - msg := toMessage(t, response.Body.String()) - require.Equal(t, "myfile.txt", msg.Attachment.Name) - require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) - require.Equal(t, int64(21), msg.Attachment.Size) - require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) - require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) - - path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") - response = request(t, s, "GET", path, "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, "21", response.Header().Get("Content-Length")) - require.Equal(t, content, response.Body.String()) - - // Slightly unrelated cross-test: make sure we add an owner for internal attachments - size, err := s.messageCache.AttachmentBytesUsedBySender("1.2.3.4") - require.Nil(t, err) - require.Equal(t, int64(21), size) } func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "", map[string]string{ - "Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", - }) - msg := toMessage(t, response.Body.String()) - require.Equal(t, "You received a file: Pink_flower.jpg", msg.Message) - require.Equal(t, "Pink_flower.jpg", msg.Attachment.Name) - require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) - require.Equal(t, "", msg.Attachment.Type) - require.Equal(t, int64(0), msg.Attachment.Size) - require.Equal(t, int64(0), msg.Attachment.Expires) - require.Equal(t, netip.Addr{}, msg.Sender) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "", map[string]string{ + "Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", + }) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "You received a file: Pink_flower.jpg", msg.Message) + require.Equal(t, "Pink_flower.jpg", msg.Attachment.Name) + require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) + require.Equal(t, "", msg.Attachment.Type) + require.Equal(t, int64(0), msg.Attachment.Size) + require.Equal(t, int64(0), msg.Attachment.Expires) + require.Equal(t, netip.Addr{}, msg.Sender) - // Slightly unrelated cross-test: make sure we don't add an owner for external attachments - size, err := s.messageCache.AttachmentBytesUsedBySender("127.0.0.1") - require.Nil(t, err) - require.Equal(t, int64(0), size) + // Slightly unrelated cross-test: make sure we don't add an owner for external attachments + size, err := s.messageCache.AttachmentBytesUsedBySender("127.0.0.1") + require.Nil(t, err) + require.Equal(t, int64(0), size) + }) } func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "This is a custom message", map[string]string{ - "X-Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", - "File": "some file.jpg", + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "This is a custom message", map[string]string{ + "X-Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", + "File": "some file.jpg", + }) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "This is a custom message", msg.Message) + require.Equal(t, "some file.jpg", msg.Attachment.Name) + require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) + require.Equal(t, "", msg.Attachment.Type) + require.Equal(t, int64(0), msg.Attachment.Size) + require.Equal(t, int64(0), msg.Attachment.Expires) + require.Equal(t, netip.Addr{}, msg.Sender) }) - msg := toMessage(t, response.Body.String()) - require.Equal(t, "This is a custom message", msg.Message) - require.Equal(t, "some file.jpg", msg.Attachment.Name) - require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) - require.Equal(t, "", msg.Attachment.Type) - require.Equal(t, int64(0), msg.Attachment.Size) - require.Equal(t, int64(0), msg.Attachment.Expires) - require.Equal(t, netip.Addr{}, msg.Sender) } func TestServer_PublishAttachmentBadURL(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?a=not+a+URL", "", nil) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 400, response.Code) - require.Equal(t, 400, err.HTTPCode) - require.Equal(t, 40013, err.Code) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?a=not+a+URL", "", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40013, err.Code) + }) } func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) { - content := util.RandomString(5000) // > 4096 - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", content, map[string]string{ - "Content-Length": "20000000", + forEachBackend(t, func(t *testing.T) { + content := util.RandomString(5000) // > 4096 + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", content, map[string]string{ + "Content-Length": "20000000", + }) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 413, response.Code) + require.Equal(t, 413, err.HTTPCode) + require.Equal(t, 41301, err.Code) }) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 413, response.Code) - require.Equal(t, 413, err.HTTPCode) - require.Equal(t, 41301, err.Code) } func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) { - content := util.RandomString(5001) // > 5000, see below - c := newTestConfig(t) - c.AttachmentFileSizeLimit = 5000 - s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", content, nil) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 413, response.Code) - require.Equal(t, 413, err.HTTPCode) - require.Equal(t, 41301, err.Code) + forEachBackend(t, func(t *testing.T) { + content := util.RandomString(5001) // > 5000, see below + c := newTestConfig(t) + c.AttachmentFileSizeLimit = 5000 + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", content, nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 413, response.Code) + require.Equal(t, 413, err.HTTPCode) + require.Equal(t, 41301, err.Code) + }) } func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) { - c := newTestConfig(t) - c.AttachmentExpiryDuration = 10 * time.Minute - s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), map[string]string{ - "Delay": "11 min", // > AttachmentExpiryDuration + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.AttachmentExpiryDuration = 10 * time.Minute + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), map[string]string{ + "Delay": "11 min", // > AttachmentExpiryDuration + }) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40015, err.Code) }) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 400, response.Code) - require.Equal(t, 400, err.HTTPCode) - require.Equal(t, 40015, err.Code) } func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) { - c := newTestConfig(t) - c.VisitorAttachmentTotalSizeLimit = 10000 - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorAttachmentTotalSizeLimit = 10000 + s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", "text file!"+util.RandomString(4990), nil) - msg := toMessage(t, response.Body.String()) - require.Equal(t, 200, response.Code) - require.Equal(t, "You received a file: attachment.txt", msg.Message) - require.Equal(t, int64(5000), msg.Attachment.Size) + response := request(t, s, "PUT", "/mytopic", "text file!"+util.RandomString(4990), nil) + msg := toMessage(t, response.Body.String()) + require.Equal(t, 200, response.Code) + require.Equal(t, "You received a file: attachment.txt", msg.Message) + require.Equal(t, int64(5000), msg.Attachment.Size) - content := util.RandomString(5001) // 5000+5001 > , see below - response = request(t, s, "PUT", "/mytopic", content, nil) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 413, response.Code) - require.Equal(t, 413, err.HTTPCode) - require.Equal(t, 41301, err.Code) + content := util.RandomString(5001) // 5000+5001 > , see below + response = request(t, s, "PUT", "/mytopic", content, nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 413, response.Code) + require.Equal(t, 413, err.HTTPCode) + require.Equal(t, 41301, err.Code) + }) } func TestServer_PublishAttachmentAndExpire(t *testing.T) { - t.Parallel() - content := util.RandomString(5000) // > 4096 + forEachBackend(t, func(t *testing.T) { + t.Parallel() + content := util.RandomString(5000) // > 4096 - c := newTestConfig(t) - c.AttachmentExpiryDuration = time.Millisecond // Hack - s := newTestServer(t, c) + c := newTestConfig(t) + c.AttachmentExpiryDuration = time.Millisecond // Hack + s := newTestServer(t, c) - // Publish and make sure we can retrieve it - response := request(t, s, "PUT", "/mytopic", content, nil) - msg := toMessage(t, response.Body.String()) - require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) - require.FileExists(t, file) - - path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") - response = request(t, s, "GET", path, "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, content, response.Body.String()) - - // Prune and makes sure it's gone - waitFor(t, func() bool { - s.execManager() // May run many times - return !util.FileExists(file) - }) - response = request(t, s, "GET", path, "", nil) - require.Equal(t, 404, response.Code) -} - -func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) { - t.Parallel() - content := util.RandomString(5000) // > 4096 - - c := newTestConfigWithAuthFile(t) - c.AttachmentExpiryDuration = time.Millisecond // Hack - s := newTestServer(t, c) - - // Create tier with certain limits - sevenDays := time.Duration(604800) * time.Second - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "test", - MessageLimit: 10, - MessageExpiryDuration: sevenDays, - AttachmentFileSizeLimit: 50_000, - AttachmentTotalSizeLimit: 200_000, - AttachmentExpiryDuration: sevenDays, // 7 days - AttachmentBandwidthLimit: 100000, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "test")) - - // Publish and make sure we can retrieve it - response := request(t, s, "PUT", "/mytopic", content, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - msg := toMessage(t, response.Body.String()) - require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix()) - require.True(t, msg.Expires > time.Now().Add(sevenDays-30*time.Second).Unix()) - file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) - require.FileExists(t, file) - - path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") - response = request(t, s, "GET", path, "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, content, response.Body.String()) - - // Prune and makes sure it's still there - time.Sleep(time.Second) // Sigh ... - s.execManager() - require.FileExists(t, file) - response = request(t, s, "GET", path, "", nil) - require.Equal(t, 200, response.Code) -} - -func TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) { - content := util.RandomString(5000) // > 4096 - - c := newTestConfigWithAuthFile(t) - c.VisitorAttachmentDailyBandwidthLimit = 1000 // Much lower than tier bandwidth! - s := newTestServer(t, c) - - // Create tier with certain limits - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "test", - MessageLimit: 10, - MessageExpiryDuration: time.Hour, - AttachmentFileSizeLimit: 50_000, - AttachmentTotalSizeLimit: 200_000, - AttachmentExpiryDuration: time.Hour, - AttachmentBandwidthLimit: 14000, // < 3x5000 bytes -> enough for one upload, one download - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "test")) - - // Publish and make sure we can retrieve it - rr := request(t, s, "PUT", "/mytopic", content, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - msg := toMessage(t, rr.Body.String()) - - // Retrieve it (first time succeeds) - rr = request(t, s, "GET", "/file/"+msg.ID, content, nil) // File downloads do not send auth headers!! - require.Equal(t, 200, rr.Code) - require.Equal(t, content, rr.Body.String()) - - // Retrieve it AGAIN (fails, due to bandwidth limit) - rr = request(t, s, "GET", "/file/"+msg.ID, content, nil) - require.Equal(t, 429, rr.Code) -} - -func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) { - smallFile := util.RandomString(20_000) - largeFile := util.RandomString(50_000) - - c := newTestConfigWithAuthFile(t) - c.AttachmentFileSizeLimit = 20_000 - c.VisitorAttachmentTotalSizeLimit = 40_000 - s := newTestServer(t, c) - - // Create tier with certain limits - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "test", - MessageLimit: 100, - AttachmentFileSizeLimit: 50_000, - AttachmentTotalSizeLimit: 200_000, - AttachmentExpiryDuration: 30 * time.Second, - AttachmentBandwidthLimit: 1000000, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "test")) - - // Publish small file as anonymous - response := request(t, s, "PUT", "/mytopic", smallFile, nil) - msg := toMessage(t, response.Body.String()) - require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) - - // Publish large file as anonymous - response = request(t, s, "PUT", "/mytopic", largeFile, nil) - require.Equal(t, 413, response.Code) - require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) - - // Publish too large file as phil - response = request(t, s, "PUT", "/mytopic", largeFile+" a few more bytes", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 413, response.Code) - require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) - - // Publish large file as phil (4x) - for i := 0; i < 4; i++ { - response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - msg = toMessage(t, response.Body.String()) - require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) - } - response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 413, response.Code) - require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) { - content := util.RandomString(5000) // > 4096 - - c := newTestConfig(t) - c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads - s := newTestServer(t, c) - - // Publish attachment - response := request(t, s, "PUT", "/mytopic", content, nil) - msg := toMessage(t, response.Body.String()) - require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - - // Value it 4 times successfully - path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") - for i := 1; i <= 4; i++ { // 4 successful downloads - response = request(t, s, "GET", path, "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, content, response.Body.String()) - } - - // And then fail with a 429 - response = request(t, s, "GET", path, "", nil) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 429, response.Code) - require.Equal(t, 42905, err.Code) -} - -func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) { - content := util.RandomString(5000) // > 4096 - - c := newTestConfig(t) - c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 500 // 5 successful uploads - s := newTestServer(t, c) - - // 5 successful uploads - for i := 1; i <= 5; i++ { + // Publish and make sure we can retrieve it response := request(t, s, "PUT", "/mytopic", content, nil) msg := toMessage(t, response.Body.String()) require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - } + file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) + require.FileExists(t, file) - // And a failed one - response := request(t, s, "PUT", "/mytopic", content, nil) - err := toHTTPError(t, response.Body.String()) - require.Equal(t, 413, response.Code) - require.Equal(t, 41301, err.Code) + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, content, response.Body.String()) + + // Prune and makes sure it's gone + waitFor(t, func() bool { + s.execManager() // May run many times + return !util.FileExists(file) + }) + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 404, response.Code) + }) +} + +func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + t.Parallel() + content := util.RandomString(5000) // > 4096 + + c := newTestConfigWithAuthFile(t) + c.AttachmentExpiryDuration = time.Millisecond // Hack + s := newTestServer(t, c) + + // Create tier with certain limits + sevenDays := time.Duration(604800) * time.Second + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 10, + MessageExpiryDuration: sevenDays, + AttachmentFileSizeLimit: 50_000, + AttachmentTotalSizeLimit: 200_000, + AttachmentExpiryDuration: sevenDays, // 7 days + AttachmentBandwidthLimit: 100000, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + + // Publish and make sure we can retrieve it + response := request(t, s, "PUT", "/mytopic", content, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix()) + require.True(t, msg.Expires > time.Now().Add(sevenDays-30*time.Second).Unix()) + file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) + require.FileExists(t, file) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, content, response.Body.String()) + + // Prune and makes sure it's still there + time.Sleep(time.Second) // Sigh ... + s.execManager() + require.FileExists(t, file) + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + }) +} + +func TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + content := util.RandomString(5000) // > 4096 + + c := newTestConfigWithAuthFile(t) + c.VisitorAttachmentDailyBandwidthLimit = 1000 // Much lower than tier bandwidth! + s := newTestServer(t, c) + + // Create tier with certain limits + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 10, + MessageExpiryDuration: time.Hour, + AttachmentFileSizeLimit: 50_000, + AttachmentTotalSizeLimit: 200_000, + AttachmentExpiryDuration: time.Hour, + AttachmentBandwidthLimit: 14000, // < 3x5000 bytes -> enough for one upload, one download + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + + // Publish and make sure we can retrieve it + rr := request(t, s, "PUT", "/mytopic", content, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + msg := toMessage(t, rr.Body.String()) + + // Retrieve it (first time succeeds) + rr = request(t, s, "GET", "/file/"+msg.ID, content, nil) // File downloads do not send auth headers!! + require.Equal(t, 200, rr.Code) + require.Equal(t, content, rr.Body.String()) + + // Retrieve it AGAIN (fails, due to bandwidth limit) + rr = request(t, s, "GET", "/file/"+msg.ID, content, nil) + require.Equal(t, 429, rr.Code) + }) +} + +func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + smallFile := util.RandomString(20_000) + largeFile := util.RandomString(50_000) + + c := newTestConfigWithAuthFile(t) + c.AttachmentFileSizeLimit = 20_000 + c.VisitorAttachmentTotalSizeLimit = 40_000 + s := newTestServer(t, c) + + // Create tier with certain limits + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "test", + MessageLimit: 100, + AttachmentFileSizeLimit: 50_000, + AttachmentTotalSizeLimit: 200_000, + AttachmentExpiryDuration: 30 * time.Second, + AttachmentBandwidthLimit: 1000000, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "test")) + + // Publish small file as anonymous + response := request(t, s, "PUT", "/mytopic", smallFile, nil) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + + // Publish large file as anonymous + response = request(t, s, "PUT", "/mytopic", largeFile, nil) + require.Equal(t, 413, response.Code) + require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) + + // Publish too large file as phil + response = request(t, s, "PUT", "/mytopic", largeFile+" a few more bytes", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 413, response.Code) + require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) + + // Publish large file as phil (4x) + for i := 0; i < 4; i++ { + response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + msg = toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + } + response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 413, response.Code) + require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code) + }) +} + +func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + content := util.RandomString(5000) // > 4096 + + c := newTestConfig(t) + c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads + s := newTestServer(t, c) + + // Publish attachment + response := request(t, s, "PUT", "/mytopic", content, nil) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + + // Value it 4 times successfully + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + for i := 1; i <= 4; i++ { // 4 successful downloads + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, content, response.Body.String()) + } + + // And then fail with a 429 + response = request(t, s, "GET", path, "", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 429, response.Code) + require.Equal(t, 42905, err.Code) + }) +} + +func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) { + forEachBackend(t, func(t *testing.T) { + content := util.RandomString(5000) // > 4096 + + c := newTestConfig(t) + c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 500 // 5 successful uploads + s := newTestServer(t, c) + + // 5 successful uploads + for i := 1; i <= 5; i++ { + response := request(t, s, "PUT", "/mytopic", content, nil) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + } + + // And a failed one + response := request(t, s, "PUT", "/mytopic", content, nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 413, response.Code) + require.Equal(t, 41301, err.Code) + }) } func TestServer_PublishAttachmentAndImmediatelyGetItWithCacheTimeout(t *testing.T) { - // This tests the awkward util.Retry in handleFile: Due to the async persisting of messages, - // the message is not immediately available when attempting to download it. + forEachBackend(t, func(t *testing.T) { + // This tests the awkward util.Retry in handleFile: Due to the async persisting of messages, + // the message is not immediately available when attempting to download it. - c := newTestConfig(t) - c.CacheBatchTimeout = 500 * time.Millisecond - c.CacheBatchSize = 10 - s := newTestServer(t, c) - content := "this is an ATTACHMENT" - rr := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil) - m := toMessage(t, rr.Body.String()) - require.Equal(t, "myfile.txt", m.Attachment.Name) + c := newTestConfig(t) + c.CacheBatchTimeout = 500 * time.Millisecond + c.CacheBatchSize = 10 + s := newTestServer(t, c) + content := "this is an ATTACHMENT" + rr := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil) + m := toMessage(t, rr.Body.String()) + require.Equal(t, "myfile.txt", m.Attachment.Name) - path := strings.TrimPrefix(m.Attachment.URL, "http://127.0.0.1:12345") - rr = request(t, s, "GET", path, "", nil) - require.Equal(t, 200, rr.Code) // Not 404! - require.Equal(t, content, rr.Body.String()) + path := strings.TrimPrefix(m.Attachment.URL, "http://127.0.0.1:12345") + rr = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, rr.Code) // Not 404! + require.Equal(t, content, rr.Body.String()) + }) } func TestServer_PublishAttachmentAccountStats(t *testing.T) { - content := util.RandomString(4999) // > 4096 + forEachBackend(t, func(t *testing.T) { + content := util.RandomString(4999) // > 4096 - c := newTestConfig(t) - c.AttachmentFileSizeLimit = 5000 - c.VisitorAttachmentTotalSizeLimit = 6000 - s := newTestServer(t, c) + c := newTestConfig(t) + c.AttachmentFileSizeLimit = 5000 + c.VisitorAttachmentTotalSizeLimit = 6000 + s := newTestServer(t, c) - // Upload one attachment - response := request(t, s, "PUT", "/mytopic", content, nil) - msg := toMessage(t, response.Body.String()) - require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + // Upload one attachment + response := request(t, s, "PUT", "/mytopic", content, nil) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") - // User stats - response = request(t, s, "GET", "/v1/account", "", nil) - require.Equal(t, 200, response.Code) - account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - require.Equal(t, int64(5000), account.Limits.AttachmentFileSize) - require.Equal(t, int64(6000), account.Limits.AttachmentTotalSize) - require.Equal(t, int64(4999), account.Stats.AttachmentTotalSize) - require.Equal(t, int64(1001), account.Stats.AttachmentTotalSizeRemaining) - require.Equal(t, int64(1), account.Stats.Messages) + // User stats + response = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, response.Code) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) + require.Nil(t, err) + require.Equal(t, int64(5000), account.Limits.AttachmentFileSize) + require.Equal(t, int64(6000), account.Limits.AttachmentTotalSize) + require.Equal(t, int64(4999), account.Stats.AttachmentTotalSize) + require.Equal(t, int64(1001), account.Stats.AttachmentTotalSizeRemaining) + require.Equal(t, int64(1), account.Stats.Messages) + }) } func TestServer_Visitor_XForwardedFor_None(t *testing.T) { - c := newTestConfig(t) - c.BehindProxy = true - s := newTestServer(t, c) - r, _ := http.NewRequest("GET", "/bla", nil) - r.RemoteAddr = "8.9.10.11:1234" - r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty! - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.Equal(t, "8.9.10.11", v.ip.String()) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "8.9.10.11:1234" + r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty! + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "8.9.10.11", v.ip.String()) + }) } func TestServer_Visitor_XForwardedFor_Single(t *testing.T) { - c := newTestConfig(t) - c.BehindProxy = true - s := newTestServer(t, c) - r, _ := http.NewRequest("GET", "/bla", nil) - r.RemoteAddr = "8.9.10.11:1234" - r.Header.Set("X-Forwarded-For", "1.1.1.1") - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.Equal(t, "1.1.1.1", v.ip.String()) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "8.9.10.11:1234" + r.Header.Set("X-Forwarded-For", "1.1.1.1") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "1.1.1.1", v.ip.String()) + }) } func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) { - c := newTestConfig(t) - c.BehindProxy = true - s := newTestServer(t, c) - r, _ := http.NewRequest("GET", "/bla", nil) - r.RemoteAddr = "8.9.10.11:1234" - r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ") - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.Equal(t, "234.5.2.1", v.ip.String()) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "8.9.10.11:1234" + r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "234.5.2.1", v.ip.String()) + }) } func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) { - c := newTestConfig(t) - c.BehindProxy = true - c.ProxyForwardedHeader = "X-Client-IP" - s := newTestServer(t, c) - r, _ := http.NewRequest("GET", "/bla", nil) - r.RemoteAddr = "8.9.10.11:1234" - r.Header.Set("X-Client-IP", "1.2.3.4") - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.Equal(t, "1.2.3.4", v.ip.String()) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + c.ProxyForwardedHeader = "X-Client-IP" + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "8.9.10.11:1234" + r.Header.Set("X-Client-IP", "1.2.3.4") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "1.2.3.4", v.ip.String()) + }) } func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) { - c := newTestConfig(t) - c.BehindProxy = true - c.ProxyForwardedHeader = "X-Client-IP" - s := newTestServer(t, c) - r, _ := http.NewRequest("GET", "/bla", nil) - r.RemoteAddr = "[2001:db8:9999::1]:1234" - r.Header.Set("X-Client-IP", "2001:db8:7777::1") - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.Equal(t, "2001:db8:7777::1", v.ip.String()) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + c.ProxyForwardedHeader = "X-Client-IP" + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "[2001:db8:9999::1]:1234" + r.Header.Set("X-Client-IP", "2001:db8:7777::1") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "2001:db8:7777::1", v.ip.String()) + }) } func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) { - c := newTestConfig(t) - c.BehindProxy = true - c.ProxyForwardedHeader = "Forwarded" - c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")} - s := newTestServer(t, c) - r, _ := http.NewRequest("GET", "/bla", nil) - r.RemoteAddr = "8.9.10.11:1234" - r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4") - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.Equal(t, "5.6.7.8", v.ip.String()) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + c.ProxyForwardedHeader = "Forwarded" + c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")} + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "8.9.10.11:1234" + r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "5.6.7.8", v.ip.String()) + }) } func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) { - c := newTestConfig(t) - c.BehindProxy = true - c.ProxyForwardedHeader = "Forwarded" - c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:1111::/64")} - s := newTestServer(t, c) - r, _ := http.NewRequest("GET", "/bla", nil) - r.RemoteAddr = "[2001:db8:2222::1]:1234" - r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]") - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.Equal(t, "2001:db8:3333::1", v.ip.String()) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + c.ProxyForwardedHeader = "Forwarded" + c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:1111::/64")} + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "[2001:db8:2222::1]:1234" + r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "2001:db8:3333::1", v.ip.String()) + }) } func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { - t.Parallel() - count := 50000 - c := newTestConfig(t) - c.TotalTopicLimit = 50001 - c.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + count := 50000 + c := newTestConfig(t) + c.TotalTopicLimit = 50001 + c.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" + s := newTestServer(t, c) - // Add lots of messages - log.Info("Adding %d messages", count) - start := time.Now() - messages := make([]*model.Message, 0) - for i := 0; i < count; i++ { - topicID := fmt.Sprintf("topic%d", i) - _, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array - require.Nil(t, err) - messages = append(messages, newDefaultMessage(topicID, "some message")) - } - require.Nil(t, s.messageCache.AddMessages(messages)) - log.Info("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond)) - - // Update stats - statsChan := make(chan bool) - go func() { - log.Info("Updating stats") + // Add lots of messages + log.Info("Adding %d messages", count) start := time.Now() - s.execManager() - log.Info("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond)) - statsChan <- true - }() - time.Sleep(50 * time.Millisecond) // Make sure it starts first + messages := make([]*model.Message, 0) + for i := 0; i < count; i++ { + topicID := fmt.Sprintf("topic%d", i) + _, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array + require.Nil(t, err) + messages = append(messages, newDefaultMessage(topicID, "some message")) + } + require.Nil(t, s.messageCache.AddMessages(messages)) + log.Info("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond)) - // Publish message (during stats update) - log.Info("Publishing message") - start = time.Now() - response := request(t, s, "PUT", "/mytopic", "some body", nil) - m := toMessage(t, response.Body.String()) - require.Equal(t, "some body", m.Message) - require.True(t, time.Since(start) < 100*time.Millisecond) - log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) + // Update stats + statsChan := make(chan bool) + go func() { + log.Info("Updating stats") + start := time.Now() + s.execManager() + log.Info("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond)) + statsChan <- true + }() + time.Sleep(50 * time.Millisecond) // Make sure it starts first - // Wait for all goroutines - select { - case <-statsChan: - case <-time.After(10 * time.Second): - t.Fatal("Timed out waiting for Go routines") - } - log.Info("Done: Waiting for all locks") + // Publish message (during stats update) + log.Info("Publishing message") + start = time.Now() + response := request(t, s, "PUT", "/mytopic", "some body", nil) + m := toMessage(t, response.Body.String()) + require.Equal(t, "some body", m.Message) + require.True(t, time.Since(start) < 100*time.Millisecond) + log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) + + // Wait for all goroutines + select { + case <-statsChan: + case <-time.After(10 * time.Second): + t.Fatal("Timed out waiting for Go routines") + } + log.Info("Done: Waiting for all locks") + }) } func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) { - conf := newTestConfigWithAuthFile(t) - s := newTestServer(t, conf) - defer s.closeDatabases() + forEachBackend(t, func(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + s := newTestServer(t, conf) + defer s.closeDatabases() - // Create user without tier - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + // Create user without tier + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - // Publish a message (anonymous user) - rr := request(t, s, "POST", "/mytopic", "hi", nil) - require.Equal(t, 200, rr.Code) + // Publish a message (anonymous user) + rr := request(t, s, "POST", "/mytopic", "hi", nil) + require.Equal(t, 200, rr.Code) - // Publish a message (non-tier user) - rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), + // Publish a message (non-tier user) + rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + // User stats (anonymous user) + rr = request(t, s, "GET", "/v1/account", "", nil) + account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(2), account.Stats.Messages) + + // User stats (non-tier user) + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(2), account.Stats.Messages) }) - require.Equal(t, 200, rr.Code) - - // User stats (anonymous user) - rr = request(t, s, "GET", "/v1/account", "", nil) - account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, int64(2), account.Stats.Messages) - - // User stats (non-tier user) - rr = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) - require.Equal(t, int64(2), account.Stats.Messages) } func TestServer_SubscriberRateLimiting_Success(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) - // "Register" visitor 1.2.3.4 to topic "upAAAAAAAAAAAA" as a rate limit visitor - subscriber1Fn := func(r *http.Request) { - r.RemoteAddr = "1.2.3.4:1234" - } - rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriber1Fn) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String()) - - // "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name) - subscriber2Fn := func(r *http.Request) { - r.RemoteAddr = "8.7.7.1:1234" - } - rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Equal(t, "8.7.7.1", s.topics["up012345678912"].rateVisitor.ip.String()) - - // Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the - // GET request before is also counted towards the request limiter. - for i := 0; i < 2; i++ { - rr := request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil) + // "Register" visitor 1.2.3.4 to topic "upAAAAAAAAAAAA" as a rate limit visitor + subscriber1Fn := func(r *http.Request) { + r.RemoteAddr = "1.2.3.4:1234" + } + rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriber1Fn) require.Equal(t, 200, rr.Code) - } - rr = request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil) - require.Equal(t, 429, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String()) - // Publish another 2 messages to "up012345678912" as visitor 9.9.9.9 - for i := 0; i < 2; i++ { - rr := request(t, s, "PUT", "/up012345678912", "some message", nil) - require.Equal(t, 200, rr.Code) // If we fail here, handlePublish is using the wrong visitor! - } - rr = request(t, s, "PUT", "/up012345678912", "some message", nil) - require.Equal(t, 429, rr.Code) - - // Hurray! At this point, visitor 9.9.9.9 has published 4 messages, even though - // VisitorRequestLimitBurst is 3. That means it's working. - - // Now let's confirm that so far we haven't used up any of visitor 9.9.9.9's request limiter - // by publishing another 3 requests from it. - for i := 0; i < 3; i++ { - rr := request(t, s, "PUT", "/some-other-topic", "some message", nil) + // "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name) + subscriber2Fn := func(r *http.Request) { + r.RemoteAddr = "8.7.7.1:1234" + } + rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn) require.Equal(t, 200, rr.Code) - } - rr = request(t, s, "PUT", "/some-other-topic", "some message", nil) - require.Equal(t, 429, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Equal(t, "8.7.7.1", s.topics["up012345678912"].rateVisitor.ip.String()) + + // Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the + // GET request before is also counted towards the request limiter. + for i := 0; i < 2; i++ { + rr := request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil) + require.Equal(t, 200, rr.Code) + } + rr = request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil) + require.Equal(t, 429, rr.Code) + + // Publish another 2 messages to "up012345678912" as visitor 9.9.9.9 + for i := 0; i < 2; i++ { + rr := request(t, s, "PUT", "/up012345678912", "some message", nil) + require.Equal(t, 200, rr.Code) // If we fail here, handlePublish is using the wrong visitor! + } + rr = request(t, s, "PUT", "/up012345678912", "some message", nil) + require.Equal(t, 429, rr.Code) + + // Hurray! At this point, visitor 9.9.9.9 has published 4 messages, even though + // VisitorRequestLimitBurst is 3. That means it's working. + + // Now let's confirm that so far we haven't used up any of visitor 9.9.9.9's request limiter + // by publishing another 3 requests from it. + for i := 0; i < 3; i++ { + rr := request(t, s, "PUT", "/some-other-topic", "some message", nil) + require.Equal(t, 200, rr.Code) + } + rr = request(t, s, "PUT", "/some-other-topic", "some message", nil) + require.Equal(t, 429, rr.Code) + }) } func TestServer_SubscriberRateLimiting_NotWrongTopic(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) - subscriberFn := func(r *http.Request) { - r.RemoteAddr = "1.2.3.4:1234" - } - rr := request(t, s, "GET", "/alerts,upAAAAAAAAAAAA,upBBBBBBBBBBBB/json?poll=1", "", nil, subscriberFn) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Nil(t, s.topics["alerts"].rateVisitor) - require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String()) - require.Equal(t, "1.2.3.4", s.topics["upBBBBBBBBBBBB"].rateVisitor.ip.String()) + subscriberFn := func(r *http.Request) { + r.RemoteAddr = "1.2.3.4:1234" + } + rr := request(t, s, "GET", "/alerts,upAAAAAAAAAAAA,upBBBBBBBBBBBB/json?poll=1", "", nil, subscriberFn) + require.Equal(t, 200, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Nil(t, s.topics["alerts"].rateVisitor) + require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String()) + require.Equal(t, "1.2.3.4", s.topics["upBBBBBBBBBBBB"].rateVisitor.ip.String()) + }) } func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = false - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = false + s := newTestServer(t, c) - // Subscriber rate limiting is disabled! + // Subscriber rate limiting is disabled! - // Registering visitor 1.2.3.4 to topic has no effect - rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, func(r *http.Request) { - r.RemoteAddr = "1.2.3.4:1234" - }) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor) - - // Registering visitor 8.7.7.1 to topic has no effect - rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) { - r.RemoteAddr = "8.7.7.1:1234" - }) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Nil(t, s.topics["up012345678912"].rateVisitor) - - // Publish 3 messages to "upAAAAAAAAAAAA" as visitor 9.9.9.9 - for i := 0; i < 3; i++ { - rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) + // Registering visitor 1.2.3.4 to topic has no effect + rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, func(r *http.Request) { + r.RemoteAddr = "1.2.3.4:1234" + }) require.Equal(t, 200, rr.Code) - } - rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil) - require.Equal(t, 429, rr.Code) - rr = request(t, s, "PUT", "/up012345678912", "some message", nil) - require.Equal(t, 429, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor) + + // Registering visitor 8.7.7.1 to topic has no effect + rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) { + r.RemoteAddr = "8.7.7.1:1234" + }) + require.Equal(t, 200, rr.Code) + require.Equal(t, "", rr.Body.String()) + require.Nil(t, s.topics["up012345678912"].rateVisitor) + + // Publish 3 messages to "upAAAAAAAAAAAA" as visitor 9.9.9.9 + for i := 0; i < 3; i++ { + rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) + require.Equal(t, 200, rr.Code) + } + rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil) + require.Equal(t, 429, rr.Code) + rr = request(t, s, "PUT", "/up012345678912", "some message", nil) + require.Equal(t, 429, rr.Code) + }) } func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) - // "Register" 5 different UnifiedPush visitors - for i := 0; i < 5; i++ { - subscriberFn := func(r *http.Request) { - r.RemoteAddr = fmt.Sprintf("1.2.3.%d:1234", i+1) - } - rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn) - require.Equal(t, 200, rr.Code) - } - - // Publish 2 messages per topic - for i := 0; i < 5; i++ { - for j := 0; j < 2; j++ { - rr := request(t, s, "PUT", fmt.Sprintf("/up12345678901%d?up=1", i), "some message", nil) + // "Register" 5 different UnifiedPush visitors + for i := 0; i < 5; i++ { + subscriberFn := func(r *http.Request) { + r.RemoteAddr = fmt.Sprintf("1.2.3.%d:1234", i+1) + } + rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn) require.Equal(t, 200, rr.Code) } - } + + // Publish 2 messages per topic + for i := 0; i < 5; i++ { + for j := 0; j < 2; j++ { + rr := request(t, s, "PUT", fmt.Sprintf("/up12345678901%d?up=1", i), "some message", nil) + require.Equal(t, 200, rr.Code) + } + } + }) } func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) - // "Register" 5 different UnifiedPush visitors - for i := 0; i < 5; i++ { - rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) { - r.RemoteAddr = fmt.Sprintf("1.2.3.%d:1234", i+1) - }) - require.Equal(t, 200, rr.Code) - } - - // Publish 2 messages per topic - for i := 0; i < 5; i++ { - notification := fmt.Sprintf(`{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/up12345678901%d?up=1"}]}}`, i) - for j := 0; j < 2; j++ { - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) + // "Register" 5 different UnifiedPush visitors + for i := 0; i < 5; i++ { + rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) { + r.RemoteAddr = fmt.Sprintf("1.2.3.%d:1234", i+1) + }) + require.Equal(t, 200, rr.Code) } - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 429, response.Code, notification) - require.Equal(t, 42901, toHTTPError(t, response.Body.String()).Code) - } + + // Publish 2 messages per topic + for i := 0; i < 5; i++ { + notification := fmt.Sprintf(`{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/up12345678901%d?up=1"}]}}`, i) + for j := 0; j < 2; j++ { + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) + } + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + require.Equal(t, 429, response.Code, notification) + require.Equal(t, 42901, toHTTPError(t, response.Body.String()).Code) + } + }) } func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) - // "Register" rate visitor - subscriberFn := func(r *http.Request) { - r.RemoteAddr = "1.2.3.4:1234" - } - rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriberFn) - require.Equal(t, 200, rr.Code) - require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String()) - require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor) + // "Register" rate visitor + subscriberFn := func(r *http.Request) { + r.RemoteAddr = "1.2.3.4:1234" + } + rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriberFn) + require.Equal(t, 200, rr.Code) + require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String()) + require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor) - // Publish message, observe rate visitor tokens being decreased - response := request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) - require.Equal(t, int64(1), s.topics["upAAAAAAAAAAAA"].rateVisitor.messagesLimiter.Value()) - require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor) + // Publish message, observe rate visitor tokens being decreased + response := request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) + require.Equal(t, int64(1), s.topics["upAAAAAAAAAAAA"].rateVisitor.messagesLimiter.Value()) + require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor) - // Expire visitor - s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour) - s.pruneVisitors() + // Expire visitor + s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour) + s.pruneVisitors() - // Publish message again, observe that rateVisitor is not used anymore and is reset - response = request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) - require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor) - require.Nil(t, s.visitors["ip:1.2.3.4"]) + // Publish message again, observe that rateVisitor is not used anymore and is reset + response = request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) + require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor) + require.Nil(t, s.visitors["ip:1.2.3.4"]) + }) } func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionReadWrite - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.AuthDefault = user.PermissionReadWrite + c.VisitorSubscriberRateLimiting = true + s := newTestServer(t, c) - // Create some ACLs - require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) + // Create some ACLs + require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) - // Set rate visitor as ip:1.2.3.4 on topic - // - "up123456789012": Allowed, because no ACLs and nobody owns the topic - // - "announcements": NOT allowed, because it has read-only permissions for everyone - rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) { - r.RemoteAddr = "1.2.3.4:1234" + // Set rate visitor as ip:1.2.3.4 on topic + // - "up123456789012": Allowed, because no ACLs and nobody owns the topic + // - "announcements": NOT allowed, because it has read-only permissions for everyone + rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) { + r.RemoteAddr = "1.2.3.4:1234" + }) + require.Equal(t, 200, rr.Code) + require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String()) + require.Nil(t, s.topics["announcements"].rateVisitor) }) - require.Equal(t, 200, rr.Code) - require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String()) - require.Nil(t, s.topics["announcements"].rateVisitor) } func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) { - c := newTestConfig(t) - c.ManagerInterval = 2 * time.Second - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfig(t) + c.ManagerInterval = 2 * time.Second + s := newTestServer(t, c) - // Publish some messages, and get stats - for i := 0; i < 5; i++ { - response := request(t, s, "POST", "/mytopic", "some message", nil) + // Publish some messages, and get stats + for i := 0; i < 5; i++ { + response := request(t, s, "POST", "/mytopic", "some message", nil) + require.Equal(t, 200, response.Code) + } + require.Equal(t, int64(5), s.messages) + require.Equal(t, []int64{0}, s.messagesHistory) + + response := request(t, s, "GET", "/v1/stats", "", nil) require.Equal(t, 200, response.Code) - } - require.Equal(t, int64(5), s.messages) - require.Equal(t, []int64{0}, s.messagesHistory) + require.Equal(t, `{"messages":5,"messages_rate":0}`+"\n", response.Body.String()) - response := request(t, s, "GET", "/v1/stats", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"messages":5,"messages_rate":0}`+"\n", response.Body.String()) + // Run manager and see message history update + s.execManager() + require.Equal(t, []int64{0, 5}, s.messagesHistory) - // Run manager and see message history update - s.execManager() - require.Equal(t, []int64{0, 5}, s.messagesHistory) - - response = request(t, s, "GET", "/v1/stats", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"messages":5,"messages_rate":2.5}`+"\n", response.Body.String()) // 5 messages in 2 seconds = 2.5 messages per second - - // Publish some more messages - for i := 0; i < 10; i++ { - response := request(t, s, "POST", "/mytopic", "some message", nil) + response = request(t, s, "GET", "/v1/stats", "", nil) require.Equal(t, 200, response.Code) - } - require.Equal(t, int64(15), s.messages) - require.Equal(t, []int64{0, 5}, s.messagesHistory) + require.Equal(t, `{"messages":5,"messages_rate":2.5}`+"\n", response.Body.String()) // 5 messages in 2 seconds = 2.5 messages per second - response = request(t, s, "GET", "/v1/stats", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"messages":15,"messages_rate":2.5}`+"\n", response.Body.String()) // Rate did not update yet + // Publish some more messages + for i := 0; i < 10; i++ { + response := request(t, s, "POST", "/mytopic", "some message", nil) + require.Equal(t, 200, response.Code) + } + require.Equal(t, int64(15), s.messages) + require.Equal(t, []int64{0, 5}, s.messagesHistory) - // Run manager and see message history update - s.execManager() - require.Equal(t, []int64{0, 5, 15}, s.messagesHistory) + response = request(t, s, "GET", "/v1/stats", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"messages":15,"messages_rate":2.5}`+"\n", response.Body.String()) // Rate did not update yet - response = request(t, s, "GET", "/v1/stats", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"messages":15,"messages_rate":3.75}`+"\n", response.Body.String()) // 15 messages in 4 seconds = 3.75 messages per second + // Run manager and see message history update + s.execManager() + require.Equal(t, []int64{0, 5, 15}, s.messagesHistory) + + response = request(t, s, "GET", "/v1/stats", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"messages":15,"messages_rate":3.75}`+"\n", response.Body.String()) // 15 messages in 4 seconds = 3.75 messages per second + }) } func TestServer_MessageHistoryMaxSize(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - for i := 0; i < 20; i++ { - s.messages = int64(i) - s.execManager() - } - require.Equal(t, []int64{10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, s.messagesHistory) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + for i := 0; i < 20; i++ { + s.messages = int64(i) + s.execManager() + } + require.Equal(t, []int64{10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, s.messagesHistory) + }) } func TestServer_MessageCountPersistence(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - s := newTestServer(t, c) - s.messages = 1234 - s.execManager() - waitFor(t, func() bool { - messages, err := s.messageCache.Stats() - require.Nil(t, err) - return messages == 1234 - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + s := newTestServer(t, c) + s.messages = 1234 + s.execManager() + waitFor(t, func() bool { + messages, err := s.messageCache.Stats() + require.Nil(t, err) + return messages == 1234 + }) - s = newTestServer(t, c) - require.Equal(t, int64(1234), s.messages) + s = newTestServer(t, c) + require.Equal(t, int64(1234), s.messages) + }) } func TestServer_PublishWithUTF8MimeHeader(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{ - "X-Filename": "some =?UTF-8?q?=C3=A4?=ttachment.txt", - "X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=", - "X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=", - "X-Tags": "=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=", - "X-Click": "=?uTf-8?b?aHR0cHM6Ly/wn5KpLmxh?=", - "X-Actions": "http, \"=?utf-8?q?Mettre =C3=A0 jour?=\", \"https://my.tld/webhook/netbird-update\"; =?utf-8?b?aHR0cCwg6L+Z5piv5LiA5Liq5qCH562+LCBodHRwczovL/CfkqkubGE=?=", + response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{ + "X-Filename": "some =?UTF-8?q?=C3=A4?=ttachment.txt", + "X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=", + "X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=", + "X-Tags": "=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=", + "X-Click": "=?uTf-8?b?aHR0cHM6Ly/wn5KpLmxh?=", + "X-Actions": "http, \"=?utf-8?q?Mettre =C3=A0 jour?=\", \"https://my.tld/webhook/netbird-update\"; =?utf-8?b?aHR0cCwg6L+Z5piv5LiA5Liq5qCH562+LCBodHRwczovL/CfkqkubGE=?=", + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "🇩🇪", m.Message) + require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title) + require.Equal(t, "some ättachment.txt", m.Attachment.Name) + require.Equal(t, "🇩🇪", m.Tags[0]) + require.Equal(t, "ntfy 很棒", m.Tags[1]) + require.Equal(t, "https://💩.la", m.Click) + require.Equal(t, "Mettre à jour", m.Actions[0].Label) + require.Equal(t, "http", m.Actions[1].Action) + require.Equal(t, "这是一个标签", m.Actions[1].Label) + require.Equal(t, "https://💩.la", m.Actions[1].URL) }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "🇩🇪", m.Message) - require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title) - require.Equal(t, "some ättachment.txt", m.Attachment.Name) - require.Equal(t, "🇩🇪", m.Tags[0]) - require.Equal(t, "ntfy 很棒", m.Tags[1]) - require.Equal(t, "https://💩.la", m.Click) - require.Equal(t, "Mettre à jour", m.Actions[0].Label) - require.Equal(t, "http", m.Actions[1].Action) - require.Equal(t, "这是一个标签", m.Actions[1].Label) - require.Equal(t, "https://💩.la", m.Actions[1].URL) } func TestServer_UpstreamBaseURL_Success(t *testing.T) { - t.Parallel() - var pollID atomic.Pointer[string] - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/87c9cddf7b0105f5fe849bf084c6e600be0fde99be3223335199b4965bd7b735", r.URL.Path) - require.Equal(t, "", string(body)) - require.NotEmpty(t, r.Header.Get("X-Poll-ID")) - pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) - })) - defer upstreamServer.Close() + forEachBackend(t, func(t *testing.T) { + t.Parallel() + var pollID atomic.Pointer[string] + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/87c9cddf7b0105f5fe849bf084c6e600be0fde99be3223335199b4965bd7b735", r.URL.Path) + require.Equal(t, "", string(body)) + require.NotEmpty(t, r.Header.Get("X-Poll-ID")) + pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) + })) + defer upstreamServer.Close() - c := newTestConfigWithAuthFile(t) - c.BaseURL = "http://myserver.internal" - c.UpstreamBaseURL = upstreamServer.URL - s := newTestServer(t, c) + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + s := newTestServer(t, c) - // Send message, and wait for upstream server to receive it - response := request(t, s, "PUT", "/mytopic", `hi there`, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.NotEmpty(t, m.ID) - require.Equal(t, "hi there", m.Message) - waitFor(t, func() bool { - pID := pollID.Load() - return pID != nil && *pID == m.ID + // Send message, and wait for upstream server to receive it + response := request(t, s, "PUT", "/mytopic", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) + waitFor(t, func() bool { + pID := pollID.Load() + return pID != nil && *pID == m.ID + }) }) } func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) { - t.Parallel() - var pollID atomic.Pointer[string] - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/a1c72bcb4daf5af54d13ef86aea8f76c11e8b88320d55f1811d5d7b173bcc1df", r.URL.Path) - require.Equal(t, "Bearer tk_1234567890", r.Header.Get("Authorization")) - require.Equal(t, "", string(body)) - require.NotEmpty(t, r.Header.Get("X-Poll-ID")) - pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) - })) - defer upstreamServer.Close() + forEachBackend(t, func(t *testing.T) { + t.Parallel() + var pollID atomic.Pointer[string] + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/a1c72bcb4daf5af54d13ef86aea8f76c11e8b88320d55f1811d5d7b173bcc1df", r.URL.Path) + require.Equal(t, "Bearer tk_1234567890", r.Header.Get("Authorization")) + require.Equal(t, "", string(body)) + require.NotEmpty(t, r.Header.Get("X-Poll-ID")) + pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) + })) + defer upstreamServer.Close() - c := newTestConfigWithAuthFile(t) - c.BaseURL = "http://myserver.internal" - c.UpstreamBaseURL = upstreamServer.URL - c.UpstreamAccessToken = "tk_1234567890" - s := newTestServer(t, c) + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + c.UpstreamAccessToken = "tk_1234567890" + s := newTestServer(t, c) - // Send message, and wait for upstream server to receive it - response := request(t, s, "PUT", "/mytopic1", `hi there`, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.NotEmpty(t, m.ID) - require.Equal(t, "hi there", m.Message) - waitFor(t, func() bool { - pID := pollID.Load() - return pID != nil && *pID == m.ID + // Send message, and wait for upstream server to receive it + response := request(t, s, "PUT", "/mytopic1", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) + waitFor(t, func() bool { + pID := pollID.Load() + return pID != nil && *pID == m.ID + }) }) } func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) { - t.Parallel() - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("UnifiedPush messages should not be forwarded") - })) - defer upstreamServer.Close() + forEachBackend(t, func(t *testing.T) { + t.Parallel() + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("UnifiedPush messages should not be forwarded") + })) + defer upstreamServer.Close() - c := newTestConfigWithAuthFile(t) - c.BaseURL = "http://myserver.internal" - c.UpstreamBaseURL = upstreamServer.URL - s := newTestServer(t, c) + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + s := newTestServer(t, c) - // Send UP message, this should not forward to upstream server - response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.NotEmpty(t, m.ID) - require.Equal(t, "hi there", m.Message) + // Send UP message, this should not forward to upstream server + response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) - // Forwarding is done asynchronously, so wait a bit. - // This ensures that the t.Fatal above is actually not triggered. - time.Sleep(500 * time.Millisecond) + // Forwarding is done asynchronously, so wait a bit. + // This ensures that the t.Fatal above is actually not triggered. + time.Sleep(500 * time.Millisecond) + }) } func TestServer_MessageTemplate(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "X-Message": "{{.foo}}", - "X-Title": "{{.nested.title}}", - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "X-Message": "{{.foo}}", + "X-Title": "{{.nested.title}}", + "X-Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "bar", m.Message) - require.Equal(t, "here", m.Title) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar", m.Message) + require.Equal(t, "here", m.Title) + }) } func TestServer_MessageTemplate_RepeatPlaceholder(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "Message": "{{.foo}} is {{.foo}}", - "Title": "{{.nested.title}} is {{.nested.title}}", - "Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "Message": "{{.foo}} is {{.foo}}", + "Title": "{{.nested.title}} is {{.nested.title}}", + "Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "bar is bar", m.Message) - require.Equal(t, "here is here", m.Title) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar is bar", m.Message) + require.Equal(t, "here is here", m.Title) + }) } func TestServer_MessageTemplate_JSONBody(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"}}"}` - response := request(t, s, "PUT", "/", body, map[string]string{ - "m": "{{.foo}}", - "t": "{{.nested.title}}", - "tpl": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"}}"}` + response := request(t, s, "PUT", "/", body, map[string]string{ + "m": "{{.foo}}", + "t": "{{.nested.title}}", + "tpl": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "bar", m.Message) - require.Equal(t, "here", m.Title) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar", m.Message) + require.Equal(t, "here", m.Title) + }) } func TestServer_MessageTemplate_MalformedJSONBody(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID"}` - response := request(t, s, "PUT", "/", body, map[string]string{ - "X-Message": "{{.foo}}", - "X-Title": "{{.nested.title}}", - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID"}` + response := request(t, s, "PUT", "/", body, map[string]string{ + "X-Message": "{{.foo}}", + "X-Title": "{{.nested.title}}", + "X-Template": "1", + }) - require.Equal(t, 400, response.Code) - require.Equal(t, 40042, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 400, response.Code) + require.Equal(t, 40042, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_MessageTemplate_PlaceholderTypo(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "X-Message": "{{.food}}", - "X-Title": "{{.neste.title}}", - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "X-Message": "{{.food}}", + "X-Title": "{{.neste.title}}", + "X-Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "", m.Message) - require.Equal(t, "", m.Title) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "", m.Message) + require.Equal(t, "", m.Title) + }) } func TestServer_MessageTemplate_MultiplePlaceholders(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "X-Message": "{{.foo}} is {{.nested.title}}", - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "X-Message": "{{.foo}} is {{.nested.title}}", + "X-Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "bar is here", m.Message) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar is here", m.Message) + }) } func TestServer_MessageTemplate_Range(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - jsonBody := `{"foo": "bar", "errors": [{"level": "severe", "url": "https://severe1.com"},{"level": "warning", "url": "https://warning.com"},{"level": "severe", "url": "https://severe2.com"}]}` - response := request(t, s, "PUT", "/mytopic", jsonBody, map[string]string{ - "X-Message": `Severe URLs:\n{{range .errors}}{{if eq .level "severe"}}- {{.url}}\n{{end}}{{end}}`, - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + jsonBody := `{"foo": "bar", "errors": [{"level": "severe", "url": "https://severe1.com"},{"level": "warning", "url": "https://warning.com"},{"level": "severe", "url": "https://severe2.com"}]}` + response := request(t, s, "PUT", "/mytopic", jsonBody, map[string]string{ + "X-Message": `Severe URLs:\n{{range .errors}}{{if eq .level "severe"}}- {{.url}}\n{{end}}{{end}}`, + "X-Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com", m.Message) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com", m.Message) + }) } func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.MessageSizeLimit = 25 // 25 < len(HTTP body) < 32k, and len(m.Message) < 25 - s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "X-Message": "{{.foo}}", - "X-Title": "{{.nested.title}}", - "X-Template": "yes", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.MessageSizeLimit = 25 // 25 < len(HTTP body) < 32k, and len(m.Message) < 25 + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "X-Message": "{{.foo}}", + "X-Title": "{{.nested.title}}", + "X-Template": "yes", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "bar", m.Message) - require.Equal(t, "here", m.Title) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar", m.Message) + require.Equal(t, "here", m.Title) + }) } func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageTooLong(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.MessageSizeLimit = 21 // 21 < len(HTTP body) < 32k, but !len(m.Message) < 21 - s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", `{"foo":"This is a long message"}`, map[string]string{ - "X-Message": "{{.foo}}", - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.MessageSizeLimit = 21 // 21 < len(HTTP body) < 32k, but !len(m.Message) < 21 + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", `{"foo":"This is a long message"}`, map[string]string{ + "X-Message": "{{.foo}}", + "X-Template": "1", + }) - require.Equal(t, 400, response.Code) - require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 400, response.Code) + require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_MessageTemplate_Grafana(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - body := `{"receiver":"ntfy\\.example\\.com/alerts","status":"resolved","alerts":[{"status":"resolved","labels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"annotations":{"summary":"15m load average too high"},"startsAt":"2024-03-15T02:28:00Z","endsAt":"2024-03-15T02:42:00Z","generatorURL":"localhost:3000/alerting/grafana/NW9oDw-4z/view","fingerprint":"becbfb94bd81ef48","silenceURL":"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter","dashboardURL":"","panelURL":"","values":{"B":18.98211314475876,"C":0},"valueString":"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"}],"groupLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts"},"commonLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"commonAnnotations":{"summary":"15m load average too high"},"externalURL":"localhost:3000/","version":"1","groupKey":"{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}","truncatedAlerts":0,"orgId":1,"title":"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)","state":"ok","message":"**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\n"}` - response := request(t, s, "PUT", "/mytopic?tpl=yes&title=Grafana+alert:+{{.title}}&message={{.message}}", body, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "Grafana alert: [RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)", m.Title) - require.Equal(t, `**Resolved** + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + body := `{"receiver":"ntfy\\.example\\.com/alerts","status":"resolved","alerts":[{"status":"resolved","labels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"annotations":{"summary":"15m load average too high"},"startsAt":"2024-03-15T02:28:00Z","endsAt":"2024-03-15T02:42:00Z","generatorURL":"localhost:3000/alerting/grafana/NW9oDw-4z/view","fingerprint":"becbfb94bd81ef48","silenceURL":"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter","dashboardURL":"","panelURL":"","values":{"B":18.98211314475876,"C":0},"valueString":"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"}],"groupLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts"},"commonLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"commonAnnotations":{"summary":"15m load average too high"},"externalURL":"localhost:3000/","version":"1","groupKey":"{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}","truncatedAlerts":0,"orgId":1,"title":"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)","state":"ok","message":"**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\n"}` + response := request(t, s, "PUT", "/mytopic?tpl=yes&title=Grafana+alert:+{{.title}}&message={{.message}}", body, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Grafana alert: [RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)", m.Title) + require.Equal(t, `**Resolved** Value: B=18.98211314475876, C=0 Labels: @@ -3030,156 +3317,173 @@ Annotations: - summary = 15m load average too high Source: localhost:3000/alerting/grafana/NW9oDw-4z/view Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter`, m.Message) + }) } func TestServer_MessageTemplate_GitHub(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - body := `{"action":"opened","number":1,"pull_request":{"url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1","id":1783420972,"node_id":"PR_kwDOHAbdo85qTNgs","html_url":"https://github.com/binwiederhier/dabble/pull/1","diff_url":"https://github.com/binwiederhier/dabble/pull/1.diff","patch_url":"https://github.com/binwiederhier/dabble/pull/1.patch","issue_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1","number":1,"state":"open","locked":false,"title":"A sample PR from Phil","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"body":null,"created_at":"2024-03-21T02:52:09Z","updated_at":"2024-03-21T02:52:09Z","closed_at":null,"merged_at":null,"merge_commit_sha":null,"assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"draft":false,"commits_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits","review_comments_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments","review_comment_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b","head":{"label":"binwiederhier:aa","ref":"aa","sha":"5703842cc5715ed1e358d23ebb693db09747ae9b","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"base":{"label":"binwiederhier:main","ref":"main","sha":"72d931a20bb83d123ab45accaf761150c8b01211","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"_links":{"self":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1"},"html":{"href":"https://github.com/binwiederhier/dabble/pull/1"},"issue":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1"},"comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments"},"review_comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments"},"review_comment":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits"},"statuses":{"href":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b"}},"author_association":"OWNER","auto_merge":null,"active_lock_reason":null,"merged":false,"mergeable":null,"rebaseable":null,"mergeable_state":"unknown","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":1,"additions":1,"deletions":1,"changed_files":1},"repository":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main"},"sender":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false}}` - response := request(t, s, "PUT", `/mytopic?tpl=yes&message=[{{.pull_request.head.repo.full_name}}]+Pull+request+{{if+eq+.action+"opened"}}OPENED{{else}}CLOSED{{end}}:+{{.pull_request.title}}`, body, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "", m.Title) - require.Equal(t, `[binwiederhier/dabble] Pull request OPENED: A sample PR from Phil`, m.Message) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + body := `{"action":"opened","number":1,"pull_request":{"url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1","id":1783420972,"node_id":"PR_kwDOHAbdo85qTNgs","html_url":"https://github.com/binwiederhier/dabble/pull/1","diff_url":"https://github.com/binwiederhier/dabble/pull/1.diff","patch_url":"https://github.com/binwiederhier/dabble/pull/1.patch","issue_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1","number":1,"state":"open","locked":false,"title":"A sample PR from Phil","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"body":null,"created_at":"2024-03-21T02:52:09Z","updated_at":"2024-03-21T02:52:09Z","closed_at":null,"merged_at":null,"merge_commit_sha":null,"assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"draft":false,"commits_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits","review_comments_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments","review_comment_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b","head":{"label":"binwiederhier:aa","ref":"aa","sha":"5703842cc5715ed1e358d23ebb693db09747ae9b","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"base":{"label":"binwiederhier:main","ref":"main","sha":"72d931a20bb83d123ab45accaf761150c8b01211","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"_links":{"self":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1"},"html":{"href":"https://github.com/binwiederhier/dabble/pull/1"},"issue":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1"},"comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments"},"review_comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments"},"review_comment":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits"},"statuses":{"href":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b"}},"author_association":"OWNER","auto_merge":null,"active_lock_reason":null,"merged":false,"mergeable":null,"rebaseable":null,"mergeable_state":"unknown","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":1,"additions":1,"deletions":1,"changed_files":1},"repository":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main"},"sender":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false}}` + response := request(t, s, "PUT", `/mytopic?tpl=yes&message=[{{.pull_request.head.repo.full_name}}]+Pull+request+{{if+eq+.action+"opened"}}OPENED{{else}}CLOSED{{end}}:+{{.pull_request.title}}`, body, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "", m.Title) + require.Equal(t, `[binwiederhier/dabble] Pull request OPENED: A sample PR from Phil`, m.Message) + }) } func TestServer_MessageTemplate_GitHub2(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - body := `{"action":"opened","number":1,"pull_request":{"url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1","id":1783420972,"node_id":"PR_kwDOHAbdo85qTNgs","html_url":"https://github.com/binwiederhier/dabble/pull/1","diff_url":"https://github.com/binwiederhier/dabble/pull/1.diff","patch_url":"https://github.com/binwiederhier/dabble/pull/1.patch","issue_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1","number":1,"state":"open","locked":false,"title":"A sample PR from Phil","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"body":null,"created_at":"2024-03-21T02:52:09Z","updated_at":"2024-03-21T02:52:09Z","closed_at":null,"merged_at":null,"merge_commit_sha":null,"assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"draft":false,"commits_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits","review_comments_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments","review_comment_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b","head":{"label":"binwiederhier:aa","ref":"aa","sha":"5703842cc5715ed1e358d23ebb693db09747ae9b","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"base":{"label":"binwiederhier:main","ref":"main","sha":"72d931a20bb83d123ab45accaf761150c8b01211","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"_links":{"self":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1"},"html":{"href":"https://github.com/binwiederhier/dabble/pull/1"},"issue":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1"},"comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments"},"review_comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments"},"review_comment":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits"},"statuses":{"href":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b"}},"author_association":"OWNER","auto_merge":null,"active_lock_reason":null,"merged":false,"mergeable":null,"rebaseable":null,"mergeable_state":"unknown","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":1,"additions":1,"deletions":1,"changed_files":1},"repository":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main"},"sender":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false}}` - response := request(t, s, "PUT", `/mytopic?tpl=yes&title={{if+eq+.action+"opened"}}New+PR:+%23{{.number}}+by+{{.pull_request.user.login}}{{else}}[{{.action}}]+PR:+%23{{.number}}+by+{{.pull_request.user.login}}{{end}}&message={{.pull_request.title}}+in+{{.repository.full_name}}.+View+more+at+{{.pull_request.html_url}}`, body, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, `New PR: #1 by binwiederhier`, m.Title) - require.Equal(t, `A sample PR from Phil in binwiederhier/dabble. View more at https://github.com/binwiederhier/dabble/pull/1`, m.Message) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + body := `{"action":"opened","number":1,"pull_request":{"url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1","id":1783420972,"node_id":"PR_kwDOHAbdo85qTNgs","html_url":"https://github.com/binwiederhier/dabble/pull/1","diff_url":"https://github.com/binwiederhier/dabble/pull/1.diff","patch_url":"https://github.com/binwiederhier/dabble/pull/1.patch","issue_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1","number":1,"state":"open","locked":false,"title":"A sample PR from Phil","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"body":null,"created_at":"2024-03-21T02:52:09Z","updated_at":"2024-03-21T02:52:09Z","closed_at":null,"merged_at":null,"merge_commit_sha":null,"assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"draft":false,"commits_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits","review_comments_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments","review_comment_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b","head":{"label":"binwiederhier:aa","ref":"aa","sha":"5703842cc5715ed1e358d23ebb693db09747ae9b","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"base":{"label":"binwiederhier:main","ref":"main","sha":"72d931a20bb83d123ab45accaf761150c8b01211","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"_links":{"self":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1"},"html":{"href":"https://github.com/binwiederhier/dabble/pull/1"},"issue":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1"},"comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments"},"review_comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments"},"review_comment":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits"},"statuses":{"href":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b"}},"author_association":"OWNER","auto_merge":null,"active_lock_reason":null,"merged":false,"mergeable":null,"rebaseable":null,"mergeable_state":"unknown","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":1,"additions":1,"deletions":1,"changed_files":1},"repository":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main"},"sender":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false}}` + response := request(t, s, "PUT", `/mytopic?tpl=yes&title={{if+eq+.action+"opened"}}New+PR:+%23{{.number}}+by+{{.pull_request.user.login}}{{else}}[{{.action}}]+PR:+%23{{.number}}+by+{{.pull_request.user.login}}{{end}}&message={{.pull_request.title}}+in+{{.repository.full_name}}.+View+more+at+{{.pull_request.html_url}}`, body, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `New PR: #1 by binwiederhier`, m.Title) + require.Equal(t, `A sample PR from Phil in binwiederhier/dabble. View more at https://github.com/binwiederhier/dabble/pull/1`, m.Message) + }) } func TestServer_MessageTemplate_DisallowedCalls(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - disallowedTemplates := []string{ - `{{template ""}}`, - `{{- template ""}}`, - `{{- + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + disallowedTemplates := []string{ + `{{template ""}}`, + `{{- template ""}}`, + `{{- template ""}}`, - `{{ call abc}}`, - `{{ define "aa"}}`, - `We cannot {{define "aa"}}`, - `We cannot {{ call "aa"}}`, - `We cannot {{- template "aa"}}`, - } - for _, disallowedTemplate := range disallowedTemplates { - messageTemplate := disallowedTemplate - t.Run(disallowedTemplate, func(t *testing.T) { - t.Parallel() - response := request(t, s, "PUT", `/mytopic`, `{}`, map[string]string{ - "Template": "yes", - "Message": messageTemplate, + `{{ call abc}}`, + `{{ define "aa"}}`, + `We cannot {{define "aa"}}`, + `We cannot {{ call "aa"}}`, + `We cannot {{- template "aa"}}`, + } + for _, disallowedTemplate := range disallowedTemplates { + messageTemplate := disallowedTemplate + t.Run(disallowedTemplate, func(t *testing.T) { + t.Parallel() + response := request(t, s, "PUT", `/mytopic`, `{}`, map[string]string{ + "Template": "yes", + "Message": messageTemplate, + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code) }) - require.Equal(t, 400, response.Code) - require.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code) - }) - } + } + }) } func TestServer_MessageTemplate_SprigFunctions(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - bodies := []string{ - `{"foo":"bar","nested":{"title":"here"}}`, - `{"topic":"ntfy-test"}`, - `{"topic":"another-topic"}`, - } - templates := []string{ - `{{.foo | upper}} is {{.nested.title | repeat 3}}`, - `{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`, - `{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`, - } - targets := []string{ - `BAR is hereherehere`, - `Topic: test`, - `Topic: another-topic`, - } - for i, body := range bodies { - template := templates[i] - target := targets[i] - t.Run(template, func(t *testing.T) { - response := request(t, s, "PUT", `/mytopic`, body, map[string]string{ - "Template": "yes", - "Message": template, + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + bodies := []string{ + `{"foo":"bar","nested":{"title":"here"}}`, + `{"topic":"ntfy-test"}`, + `{"topic":"another-topic"}`, + } + templates := []string{ + `{{.foo | upper}} is {{.nested.title | repeat 3}}`, + `{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`, + `{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`, + } + targets := []string{ + `BAR is hereherehere`, + `Topic: test`, + `Topic: another-topic`, + } + for i, body := range bodies { + template := templates[i] + target := targets[i] + t.Run(template, func(t *testing.T) { + response := request(t, s, "PUT", `/mytopic`, body, map[string]string{ + "Template": "yes", + "Message": template, + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, target, m.Message) }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, target, m.Message) - }) - } + } + }) } func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ - "X-Message": `{{ env "PATH" }}`, - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ env "PATH" }}`, + "X-Template": "1", + }) - require.Equal(t, 400, response.Code) - require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 400, response.Code) + require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_MessageTemplate_InlineNewlines(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{ - "X-Message": `{{"New\nlines"}}`, - "X-Title": `{{"New\nlines"}}`, - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{"New\nlines"}}`, + "X-Title": `{{"New\nlines"}}`, + "X-Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, `New + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `New lines`, m.Message) - require.Equal(t, `New + require.Equal(t, `New lines`, m.Title) + }) } func TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{ - "X-Message": `{{.foo}}{{"\n"}}{{.food}}`, - "X-Title": `{{.food}}{{"\n"}}{{.foo}}`, - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{ + "X-Message": `{{.foo}}{{"\n"}}{{.food}}`, + "X-Title": `{{.food}}{{"\n"}}{{.foo}}`, + "X-Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, `bar + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `bar bag`, m.Message) - require.Equal(t, `bag + require.Equal(t, `bag bar`, m.Title) + }) } func TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.TemplateDir = t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(` + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.TemplateDir = t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(` title: | {{.food}}{{"\n"}}{{.foo}} message: | {{.foo}}{{"\n"}}{{.food}} `), 0644)) - s := newTestServer(t, c) - response := request(t, s, "POST", "/mytopic?template=newline", `{"foo":"bar","food":"bag"}`, nil) - fmt.Println(response.Body.String()) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, `bar + s := newTestServer(t, c) + response := request(t, s, "POST", "/mytopic?template=newline", `{"foo":"bar","food":"bag"}`, nil) + fmt.Println(response.Body.String()) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `bar bag`, m.Message) - require.Equal(t, `bag + require.Equal(t, `bag bar`, m.Title) + }) } var ( @@ -3191,13 +3495,14 @@ var ( ) func TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "💬 New comment on issue #1389 instant alerts without Pull to refresh", m.Title) - require.Equal(t, `Commenter: https://github.com/wunter8 + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "💬 New comment on issue #1389 instant alerts without Pull to refresh", m.Title) + require.Equal(t, `Commenter: https://github.com/wunter8 Repository: https://github.com/binwiederhier/ntfy Comment link: https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289 @@ -3207,16 +3512,18 @@ These are the things you need to do to get iOS push notifications to work: 2. put the URL you copied in the ntfy `+"`"+`base-url`+"`"+` config in server.yml or NTFY_BASE_URL in env variables 3. put the URL you copied in the default server URL setting in the iOS ntfy app 4. set `+"`"+`upstream-base-url`+"`"+` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to "https://ntfy.sh" (without a trailing slash)`, m.Message) + }) } func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "🐛 Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title) - require.Equal(t, `Opened by: https://github.com/TheUser-dev + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "🐛 Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title) + require.Equal(t, `Opened by: https://github.com/TheUser-dev Repository: https://github.com/binwiederhier/ntfy Issue link: https://github.com/binwiederhier/ntfy/issues/1391 Labels: 🪲 bug @@ -3235,534 +3542,581 @@ closed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_me :crystal_ball: **Additional context** Looks like this has already been fixed by #498, regression?`, m.Message) + }) } func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.TemplateDir = t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(` + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.TemplateDir = t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(` title: | Custom title: action={{ .action }} trunctitle={{ .issue.title | trunc 10 }} message: | Custom message {{ .issue.number }} `), 0644)) - s := newTestServer(t, c) - response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) - fmt.Println(response.Body.String()) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "Custom title: action=opened trunctitle=http 500 e", m.Title) - require.Equal(t, "Custom message 1391", m.Message) + s := newTestServer(t, c) + response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) + fmt.Println(response.Body.String()) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Custom title: action=opened trunctitle=http 500 e", m.Title) + require.Equal(t, "Custom message 1391", m.Message) + }) } func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ - "X-Message": `{{ repeat 9999 "mystring" }}`, - "X-Template": "1", + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ repeat 9999 "mystring" }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template") }) - require.Equal(t, 400, response.Code) - require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) - require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template") } func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ - "X-Message": `{{ repeat 10001 "mystring" }}`, - "X-Template": "1", + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ repeat 10001 "mystring" }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000") }) - require.Equal(t, 400, response.Code) - require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) - require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000") } func TestServer_MessageTemplate_Until100_000(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ - "X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`, - "X-Template": "1", + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") }) - require.Equal(t, 400, response.Code) - require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) - require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") } func TestServer_MessageTemplate_Priority(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{"priority":"5"}`, map[string]string{ - "X-Message": "Test message", - "X-Priority": "{{.priority}}", - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"priority":"5"}`, map[string]string{ + "X-Message": "Test message", + "X-Priority": "{{.priority}}", + "X-Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "Test message", m.Message) - require.Equal(t, 5, m.Priority) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Test message", m.Message) + require.Equal(t, 5, m.Priority) + }) } func TestServer_MessageTemplate_Priority_Conditional(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Test with error status -> priority 5 - response := request(t, s, "PUT", "/mytopic", `{"status":"Error","message":"Something went wrong"}`, map[string]string{ - "X-Message": "Status: {{.status}} - {{.message}}", - "X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`, - "X-Template": "1", - }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "Status: Error - Something went wrong", m.Message) - require.Equal(t, 5, m.Priority) + // Test with error status -> priority 5 + response := request(t, s, "PUT", "/mytopic", `{"status":"Error","message":"Something went wrong"}`, map[string]string{ + "X-Message": "Status: {{.status}} - {{.message}}", + "X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`, + "X-Template": "1", + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Status: Error - Something went wrong", m.Message) + require.Equal(t, 5, m.Priority) - // Test with success status -> priority 3 - response = request(t, s, "PUT", "/mytopic", `{"status":"Success","message":"All good"}`, map[string]string{ - "X-Message": "Status: {{.status}} - {{.message}}", - "X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`, - "X-Template": "1", + // Test with success status -> priority 3 + response = request(t, s, "PUT", "/mytopic", `{"status":"Success","message":"All good"}`, map[string]string{ + "X-Message": "Status: {{.status}} - {{.message}}", + "X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`, + "X-Template": "1", + }) + require.Equal(t, 200, response.Code) + m = toMessage(t, response.Body.String()) + require.Equal(t, "Status: Success - All good", m.Message) + require.Equal(t, 3, m.Priority) }) - require.Equal(t, 200, response.Code) - m = toMessage(t, response.Body.String()) - require.Equal(t, "Status: Success - All good", m.Message) - require.Equal(t, 3, m.Priority) } func TestServer_MessageTemplate_Priority_NamedValue(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{"severity":"high"}`, map[string]string{ - "X-Message": "Alert", - "X-Priority": "{{.severity}}", - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"severity":"high"}`, map[string]string{ + "X-Message": "Alert", + "X-Priority": "{{.severity}}", + "X-Template": "1", + }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, 4, m.Priority) // "high" = 4 + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, 4, m.Priority) // "high" = 4 + }) } func TestServer_MessageTemplate_Priority_Invalid(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", `{"priority":"invalid"}`, map[string]string{ - "X-Message": "Test message", - "X-Priority": "{{.priority}}", - "X-Template": "1", - }) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"priority":"invalid"}`, map[string]string{ + "X-Message": "Test message", + "X-Priority": "{{.priority}}", + "X-Template": "1", + }) - require.Equal(t, 400, response.Code) - require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 400, response.Code) + require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) + }) } func TestServer_MessageTemplate_Priority_QueryParam(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic?template=1&priority={{.priority}}", `{"priority":"max"}`, nil) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?template=1&priority={{.priority}}", `{"priority":"max"}`, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, 5, m.Priority) // "max" = 5 + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, 5, m.Priority) // "max" = 5 + }) } func TestServer_MessageTemplate_Priority_FromTemplateFile(t *testing.T) { - t.Parallel() - c := newTestConfig(t) - c.TemplateDir = t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "priority-test.yml"), []byte(` + forEachBackend(t, func(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.TemplateDir = t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "priority-test.yml"), []byte(` title: "{{.title}}" message: "{{.message}}" priority: '{{if eq .level "critical"}}5{{else if eq .level "warning"}}4{{else}}3{{end}}' `), 0644)) - s := newTestServer(t, c) + s := newTestServer(t, c) - // Test with critical level - response := request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"System down","level":"critical"}`, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "Alert", m.Title) - require.Equal(t, "System down", m.Message) - require.Equal(t, 5, m.Priority) + // Test with critical level + response := request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"System down","level":"critical"}`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Alert", m.Title) + require.Equal(t, "System down", m.Message) + require.Equal(t, 5, m.Priority) - // Test with warning level - response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"High load","level":"warning"}`, nil) - require.Equal(t, 200, response.Code) - m = toMessage(t, response.Body.String()) - require.Equal(t, 4, m.Priority) + // Test with warning level + response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"High load","level":"warning"}`, nil) + require.Equal(t, 200, response.Code) + m = toMessage(t, response.Body.String()) + require.Equal(t, 4, m.Priority) - // Test with info level - response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"All good","level":"info"}`, nil) - require.Equal(t, 200, response.Code) - m = toMessage(t, response.Body.String()) - require.Equal(t, 3, m.Priority) + // Test with info level + response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"All good","level":"info"}`, nil) + require.Equal(t, 200, response.Code) + m = toMessage(t, response.Body.String()) + require.Equal(t, 3, m.Priority) + }) } func TestServer_DeleteMessage(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish a message with a sequence ID - response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil) - require.Equal(t, 200, response.Code) - msg := toMessage(t, response.Body.String()) - require.Equal(t, "seq123", msg.SequenceID) - require.Equal(t, "message", msg.Event) + // Publish a message with a sequence ID + response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "seq123", msg.SequenceID) + require.Equal(t, "message", msg.Event) - // Delete the message using DELETE method - response = request(t, s, "DELETE", "/mytopic/seq123", "", nil) - require.Equal(t, 200, response.Code) - deleteMsg := toMessage(t, response.Body.String()) - require.Equal(t, "seq123", deleteMsg.SequenceID) - require.Equal(t, "message_delete", deleteMsg.Event) + // Delete the message using DELETE method + response = request(t, s, "DELETE", "/mytopic/seq123", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq123", deleteMsg.SequenceID) + require.Equal(t, "message_delete", deleteMsg.Event) - // Poll and verify both messages are returned - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") - require.Equal(t, 2, len(lines)) + // Poll and verify both messages are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) - msg1 := toMessage(t, lines[0]) - msg2 := toMessage(t, lines[1]) - require.Equal(t, "message", msg1.Event) - require.Equal(t, "message_delete", msg2.Event) - require.Equal(t, "seq123", msg1.SequenceID) - require.Equal(t, "seq123", msg2.SequenceID) + msg1 := toMessage(t, lines[0]) + msg2 := toMessage(t, lines[1]) + require.Equal(t, "message", msg1.Event) + require.Equal(t, "message_delete", msg2.Event) + require.Equal(t, "seq123", msg1.SequenceID) + require.Equal(t, "seq123", msg2.SequenceID) + }) } func TestServer_ClearMessage(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish a message with a sequence ID - response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil) - require.Equal(t, 200, response.Code) - msg := toMessage(t, response.Body.String()) - require.Equal(t, "seq456", msg.SequenceID) - require.Equal(t, "message", msg.Event) + // Publish a message with a sequence ID + response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "seq456", msg.SequenceID) + require.Equal(t, "message", msg.Event) - // Clear the message using PUT /topic/seq/clear - response = request(t, s, "PUT", "/mytopic/seq456/clear", "", nil) - require.Equal(t, 200, response.Code) - clearMsg := toMessage(t, response.Body.String()) - require.Equal(t, "seq456", clearMsg.SequenceID) - require.Equal(t, "message_clear", clearMsg.Event) + // Clear the message using PUT /topic/seq/clear + response = request(t, s, "PUT", "/mytopic/seq456/clear", "", nil) + require.Equal(t, 200, response.Code) + clearMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq456", clearMsg.SequenceID) + require.Equal(t, "message_clear", clearMsg.Event) - // Poll and verify both messages are returned - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") - require.Equal(t, 2, len(lines)) + // Poll and verify both messages are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) - msg1 := toMessage(t, lines[0]) - msg2 := toMessage(t, lines[1]) - require.Equal(t, "message", msg1.Event) - require.Equal(t, "message_clear", msg2.Event) - require.Equal(t, "seq456", msg1.SequenceID) - require.Equal(t, "seq456", msg2.SequenceID) + msg1 := toMessage(t, lines[0]) + msg2 := toMessage(t, lines[1]) + require.Equal(t, "message", msg1.Event) + require.Equal(t, "message_clear", msg2.Event) + require.Equal(t, "seq456", msg1.SequenceID) + require.Equal(t, "seq456", msg2.SequenceID) + }) } func TestServer_ClearMessage_ReadEndpoint(t *testing.T) { - // Test that /topic/seq/read also works - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + // Test that /topic/seq/read also works + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish a message - response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil) - require.Equal(t, 200, response.Code) + // Publish a message + response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil) + require.Equal(t, 200, response.Code) - // Clear using /read endpoint - response = request(t, s, "PUT", "/mytopic/seq789/read", "", nil) - require.Equal(t, 200, response.Code) - clearMsg := toMessage(t, response.Body.String()) - require.Equal(t, "seq789", clearMsg.SequenceID) - require.Equal(t, "message_clear", clearMsg.Event) + // Clear using /read endpoint + response = request(t, s, "PUT", "/mytopic/seq789/read", "", nil) + require.Equal(t, 200, response.Code) + clearMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq789", clearMsg.SequenceID) + require.Equal(t, "message_clear", clearMsg.Event) + }) } func TestServer_UpdateMessage(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish original message - response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil) - require.Equal(t, 200, response.Code) - msg1 := toMessage(t, response.Body.String()) - require.Equal(t, "update-seq", msg1.SequenceID) - require.Equal(t, "original message", msg1.Message) + // Publish original message + response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.Equal(t, "update-seq", msg1.SequenceID) + require.Equal(t, "original message", msg1.Message) - // Update the message (same sequence ID, new content) - response = request(t, s, "PUT", "/mytopic/update-seq", "updated message", nil) - require.Equal(t, 200, response.Code) - msg2 := toMessage(t, response.Body.String()) - require.Equal(t, "update-seq", msg2.SequenceID) - require.Equal(t, "updated message", msg2.Message) - require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs + // Update the message (same sequence ID, new content) + response = request(t, s, "PUT", "/mytopic/update-seq", "updated message", nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, "update-seq", msg2.SequenceID) + require.Equal(t, "updated message", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs - // Poll and verify both versions are returned - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") - require.Equal(t, 2, len(lines)) + // Poll and verify both versions are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) - polledMsg1 := toMessage(t, lines[0]) - polledMsg2 := toMessage(t, lines[1]) - require.Equal(t, "original message", polledMsg1.Message) - require.Equal(t, "updated message", polledMsg2.Message) - require.Equal(t, "update-seq", polledMsg1.SequenceID) - require.Equal(t, "update-seq", polledMsg2.SequenceID) + polledMsg1 := toMessage(t, lines[0]) + polledMsg2 := toMessage(t, lines[1]) + require.Equal(t, "original message", polledMsg1.Message) + require.Equal(t, "updated message", polledMsg2.Message) + require.Equal(t, "update-seq", polledMsg1.SequenceID) + require.Equal(t, "update-seq", polledMsg2.SequenceID) + }) } func TestServer_UpdateMessage_UsingMessageID(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish original message without a sequence ID - response := request(t, s, "PUT", "/mytopic", "original message", nil) - require.Equal(t, 200, response.Code) - msg1 := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg1.ID) - require.Empty(t, msg1.SequenceID) // No sequence ID provided - require.Equal(t, "original message", msg1.Message) + // Publish original message without a sequence ID + response := request(t, s, "PUT", "/mytopic", "original message", nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg1.ID) + require.Empty(t, msg1.SequenceID) // No sequence ID provided + require.Equal(t, "original message", msg1.Message) - // Update the message using the message ID as the sequence ID - response = request(t, s, "PUT", "/mytopic/"+msg1.ID, "updated message", nil) - require.Equal(t, 200, response.Code) - msg2 := toMessage(t, response.Body.String()) - require.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID - require.Equal(t, "updated message", msg2.Message) - require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs + // Update the message using the message ID as the sequence ID + response = request(t, s, "PUT", "/mytopic/"+msg1.ID, "updated message", nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID + require.Equal(t, "updated message", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs - // Poll and verify both versions are returned - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") - require.Equal(t, 2, len(lines)) + // Poll and verify both versions are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) - polledMsg1 := toMessage(t, lines[0]) - polledMsg2 := toMessage(t, lines[1]) - require.Equal(t, "original message", polledMsg1.Message) - require.Equal(t, "updated message", polledMsg2.Message) - require.Empty(t, polledMsg1.SequenceID) // Original has no sequence ID - require.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID + polledMsg1 := toMessage(t, lines[0]) + polledMsg2 := toMessage(t, lines[1]) + require.Equal(t, "original message", polledMsg1.Message) + require.Equal(t, "updated message", polledMsg2.Message) + require.Empty(t, polledMsg1.SequenceID) // Original has no sequence ID + require.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID + }) } func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Test invalid sequence ID for delete (returns 404 because route doesn't match) - response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil) - require.Equal(t, 404, response.Code) + // Test invalid sequence ID for delete (returns 404 because route doesn't match) + response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil) + require.Equal(t, 404, response.Code) - // Test invalid sequence ID for clear (returns 404 because route doesn't match) - response = request(t, s, "PUT", "/mytopic/invalid*seq/clear", "", nil) - require.Equal(t, 404, response.Code) + // Test invalid sequence ID for clear (returns 404 because route doesn't match) + response = request(t, s, "PUT", "/mytopic/invalid*seq/clear", "", nil) + require.Equal(t, 404, response.Code) + }) } func TestServer_DeleteMessage_WithFirebase(t *testing.T) { - sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) - s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + forEachBackend(t, func(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) - // Publish a message - response := request(t, s, "PUT", "/mytopic/firebase-seq", "test message", nil) - require.Equal(t, 200, response.Code) + // Publish a message + response := request(t, s, "PUT", "/mytopic/firebase-seq", "test message", nil) + require.Equal(t, 200, response.Code) - time.Sleep(100 * time.Millisecond) // Firebase publishing happens - require.Equal(t, 1, len(sender.Messages())) - require.Equal(t, "message", sender.Messages()[0].Data["event"]) + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 1, len(sender.Messages())) + require.Equal(t, "message", sender.Messages()[0].Data["event"]) - // Delete the message - response = request(t, s, "DELETE", "/mytopic/firebase-seq", "", nil) - require.Equal(t, 200, response.Code) + // Delete the message + response = request(t, s, "DELETE", "/mytopic/firebase-seq", "", nil) + require.Equal(t, 200, response.Code) - time.Sleep(100 * time.Millisecond) // Firebase publishing happens - require.Equal(t, 2, len(sender.Messages())) - require.Equal(t, "message_delete", sender.Messages()[1].Data["event"]) - require.Equal(t, "firebase-seq", sender.Messages()[1].Data["sequence_id"]) + time.Sleep(100 * time.Millisecond) // Firebase publishing happens + require.Equal(t, 2, len(sender.Messages())) + require.Equal(t, "message_delete", sender.Messages()[1].Data["event"]) + require.Equal(t, "firebase-seq", sender.Messages()[1].Data["sequence_id"]) + }) } func TestServer_ClearMessage_WithFirebase(t *testing.T) { - sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) - s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) + forEachBackend(t, func(t *testing.T) { + sender := newTestFirebaseSender(10) + s := newTestServer(t, newTestConfig(t)) + s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) - // Publish a message - response := request(t, s, "PUT", "/mytopic/firebase-clear-seq", "test message", nil) - require.Equal(t, 200, response.Code) + // Publish a message + response := request(t, s, "PUT", "/mytopic/firebase-clear-seq", "test message", nil) + require.Equal(t, 200, response.Code) - time.Sleep(100 * time.Millisecond) - require.Equal(t, 1, len(sender.Messages())) + time.Sleep(100 * time.Millisecond) + require.Equal(t, 1, len(sender.Messages())) - // Clear the message - response = request(t, s, "PUT", "/mytopic/firebase-clear-seq/clear", "", nil) - require.Equal(t, 200, response.Code) + // Clear the message + response = request(t, s, "PUT", "/mytopic/firebase-clear-seq/clear", "", nil) + require.Equal(t, 200, response.Code) - time.Sleep(100 * time.Millisecond) - require.Equal(t, 2, len(sender.Messages())) - require.Equal(t, "message_clear", sender.Messages()[1].Data["event"]) - require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"]) + time.Sleep(100 * time.Millisecond) + require.Equal(t, 2, len(sender.Messages())) + require.Equal(t, "message_clear", sender.Messages()[1].Data["event"]) + require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"]) + }) } func TestServer_UpdateScheduledMessage(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish a scheduled message (future delivery) - response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil) - require.Equal(t, 200, response.Code) - msg1 := toMessage(t, response.Body.String()) - require.Equal(t, "sched-seq", msg1.SequenceID) - require.Equal(t, "original scheduled message", msg1.Message) + // Publish a scheduled message (future delivery) + response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.Equal(t, "sched-seq", msg1.SequenceID) + require.Equal(t, "original scheduled message", msg1.Message) - // Verify scheduled message exists - response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) - require.Equal(t, 200, response.Code) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "original scheduled message", messages[0].Message) + // Verify scheduled message exists + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "original scheduled message", messages[0].Message) - // Update the scheduled message (same sequence ID, new content) - response = request(t, s, "PUT", "/mytopic/sched-seq?delay=2h", "updated scheduled message", nil) - require.Equal(t, 200, response.Code) - msg2 := toMessage(t, response.Body.String()) - require.Equal(t, "sched-seq", msg2.SequenceID) - require.Equal(t, "updated scheduled message", msg2.Message) - require.NotEqual(t, msg1.ID, msg2.ID) + // Update the scheduled message (same sequence ID, new content) + response = request(t, s, "PUT", "/mytopic/sched-seq?delay=2h", "updated scheduled message", nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, "sched-seq", msg2.SequenceID) + require.Equal(t, "updated scheduled message", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) - // Verify only the updated message exists (old scheduled was deleted) - response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) - require.Equal(t, 200, response.Code) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "updated scheduled message", messages[0].Message) - require.Equal(t, msg2.ID, messages[0].ID) + // Verify only the updated message exists (old scheduled was deleted) + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "updated scheduled message", messages[0].Message) + require.Equal(t, msg2.ID, messages[0].ID) + }) } func TestServer_DeleteScheduledMessage(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish a scheduled message (future delivery) - response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil) - require.Equal(t, 200, response.Code) - msg := toMessage(t, response.Body.String()) - require.Equal(t, "delete-sched-seq", msg.SequenceID) + // Publish a scheduled message (future delivery) + response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-sched-seq", msg.SequenceID) - // Verify scheduled message exists - response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) - require.Equal(t, 200, response.Code) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "scheduled message to delete", messages[0].Message) + // Verify scheduled message exists + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "scheduled message to delete", messages[0].Message) - // Delete the scheduled message - response = request(t, s, "DELETE", "/mytopic/delete-sched-seq", "", nil) - require.Equal(t, 200, response.Code) - deleteMsg := toMessage(t, response.Body.String()) - require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID) - require.Equal(t, "message_delete", deleteMsg.Event) + // Delete the scheduled message + response = request(t, s, "DELETE", "/mytopic/delete-sched-seq", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID) + require.Equal(t, "message_delete", deleteMsg.Event) - // Verify scheduled message was deleted, only delete event remains - response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) - require.Equal(t, 200, response.Code) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "message_delete", messages[0].Event) - require.Equal(t, "delete-sched-seq", messages[0].SequenceID) + // Verify scheduled message was deleted, only delete event remains + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "message_delete", messages[0].Event) + require.Equal(t, "delete-sched-seq", messages[0].SequenceID) + }) } func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish scheduled messages with same sequence ID in different topics - response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil) - require.Equal(t, 200, response.Code) + // Publish scheduled messages with same sequence ID in different topics + response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil) + require.Equal(t, 200, response.Code) - response = request(t, s, "PUT", "/topic2/shared-seq?delay=1h", "topic2 scheduled", nil) - require.Equal(t, 200, response.Code) + response = request(t, s, "PUT", "/topic2/shared-seq?delay=1h", "topic2 scheduled", nil) + require.Equal(t, 200, response.Code) - // Update scheduled message in topic1 only - response = request(t, s, "PUT", "/topic1/shared-seq?delay=2h", "topic1 updated", nil) - require.Equal(t, 200, response.Code) + // Update scheduled message in topic1 only + response = request(t, s, "PUT", "/topic1/shared-seq?delay=2h", "topic1 updated", nil) + require.Equal(t, 200, response.Code) - // Verify topic1 has only the updated message - response = request(t, s, "GET", "/topic1/json?poll=1&scheduled=1", "", nil) - require.Equal(t, 200, response.Code) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "topic1 updated", messages[0].Message) + // Verify topic1 has only the updated message + response = request(t, s, "GET", "/topic1/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "topic1 updated", messages[0].Message) - // Verify topic2 still has its original scheduled message (not affected) - response = request(t, s, "GET", "/topic2/json?poll=1&scheduled=1", "", nil) - require.Equal(t, 200, response.Code) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, "topic2 scheduled", messages[0].Message) + // Verify topic2 still has its original scheduled message (not affected) + response = request(t, s, "GET", "/topic2/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "topic2 scheduled", messages[0].Message) + }) } func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish a scheduled message with an attachment - content := util.RandomString(5000) // > 4096 to trigger attachment - response := request(t, s, "PUT", "/mytopic/attach-seq?delay=1h", content, nil) - require.Equal(t, 200, response.Code) - msg1 := toMessage(t, response.Body.String()) - require.Equal(t, "attach-seq", msg1.SequenceID) - require.NotNil(t, msg1.Attachment) + // Publish a scheduled message with an attachment + content := util.RandomString(5000) // > 4096 to trigger attachment + response := request(t, s, "PUT", "/mytopic/attach-seq?delay=1h", content, nil) + require.Equal(t, 200, response.Code) + msg1 := toMessage(t, response.Body.String()) + require.Equal(t, "attach-seq", msg1.SequenceID) + require.NotNil(t, msg1.Attachment) - // Verify attachment file exists - attachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID) - require.FileExists(t, attachmentFile1) + // Verify attachment file exists + attachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID) + require.FileExists(t, attachmentFile1) - // Update the scheduled message with a new attachment - newContent := util.RandomString(5000) - response = request(t, s, "PUT", "/mytopic/attach-seq?delay=2h", newContent, nil) - require.Equal(t, 200, response.Code) - msg2 := toMessage(t, response.Body.String()) - require.Equal(t, "attach-seq", msg2.SequenceID) - require.NotEqual(t, msg1.ID, msg2.ID) + // Update the scheduled message with a new attachment + newContent := util.RandomString(5000) + response = request(t, s, "PUT", "/mytopic/attach-seq?delay=2h", newContent, nil) + require.Equal(t, 200, response.Code) + msg2 := toMessage(t, response.Body.String()) + require.Equal(t, "attach-seq", msg2.SequenceID) + require.NotEqual(t, msg1.ID, msg2.ID) - // Verify old attachment file was deleted - require.NoFileExists(t, attachmentFile1) + // Verify old attachment file was deleted + require.NoFileExists(t, attachmentFile1) - // Verify new attachment file exists - attachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID) - require.FileExists(t, attachmentFile2) + // Verify new attachment file exists + attachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID) + require.FileExists(t, attachmentFile2) + }) } func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - // Publish a scheduled message with an attachment - content := util.RandomString(5000) // > 4096 to trigger attachment - response := request(t, s, "PUT", "/mytopic/delete-attach-seq?delay=1h", content, nil) - require.Equal(t, 200, response.Code) - msg := toMessage(t, response.Body.String()) - require.Equal(t, "delete-attach-seq", msg.SequenceID) - require.NotNil(t, msg.Attachment) + // Publish a scheduled message with an attachment + content := util.RandomString(5000) // > 4096 to trigger attachment + response := request(t, s, "PUT", "/mytopic/delete-attach-seq?delay=1h", content, nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-attach-seq", msg.SequenceID) + require.NotNil(t, msg.Attachment) - // Verify attachment file exists - attachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID) - require.FileExists(t, attachmentFile) + // Verify attachment file exists + attachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID) + require.FileExists(t, attachmentFile) - // Delete the scheduled message - response = request(t, s, "DELETE", "/mytopic/delete-attach-seq", "", nil) - require.Equal(t, 200, response.Code) - deleteMsg := toMessage(t, response.Body.String()) - require.Equal(t, "message_delete", deleteMsg.Event) + // Delete the scheduled message + response = request(t, s, "DELETE", "/mytopic/delete-attach-seq", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "message_delete", deleteMsg.Event) - // Verify attachment file was deleted - require.NoFileExists(t, attachmentFile) + // Verify attachment file was deleted + require.NoFileExists(t, attachmentFile) + }) } func newMemTestCache(t *testing.T) message.Store { @@ -3771,19 +4125,66 @@ func newMemTestCache(t *testing.T) message.Store { return c } +var testBackendDatabaseURLs sync.Map + +func forEachBackend(t *testing.T, f func(t *testing.T)) { + t.Run("sqlite", func(t *testing.T) { + f(t) + }) + t.Run("postgres", func(t *testing.T) { + dsn := os.Getenv("NTFY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("NTFY_TEST_DATABASE_URL not set") + } + schema := fmt.Sprintf("test_%s", util.RandomString(10)) + setupDB, err := sql.Open("pgx", dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + schemaDSN := u.String() + testBackendDatabaseURLs.Store(t, schemaDSN) + t.Cleanup(func() { + testBackendDatabaseURLs.Delete(t) + cleanDB, _ := sql.Open("pgx", dsn) + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + }) + f(t) + }) +} + +func testBackendDatabaseURL(t *testing.T) string { + if dbURL, ok := testBackendDatabaseURLs.Load(t); ok { + return dbURL.(string) + } + return "" +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" - conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") - conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" + if dbURL := testBackendDatabaseURL(t); dbURL != "" { + conf.DatabaseURL = dbURL + } else { + conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") + conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" + } conf.AttachmentCacheDir = t.TempDir() conf.TemplateDir = t.TempDir() return conf } func configureAuth(t *testing.T, conf *Config) *Config { - conf.AuthFile = filepath.Join(t.TempDir(), "user.db") - conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" + if conf.DatabaseURL == "" { + conf.AuthFile = filepath.Join(t.TempDir(), "user.db") + conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" + } conf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot return conf } @@ -3959,110 +4360,114 @@ func (w *closableResponseWriter) Close() { } func TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) { - // This test reproduces the panic from https://github.com/binwiederhier/ntfy/issues/338: - // - // panic: runtime error: invalid memory address or nil pointer dereference - // bufio.(*Writer).Flush(...) - // net/http.(*response).Flush(...) - // server.(*Server).handleSubscribeHTTP.func2(...) - // server.(*topic).Publish.func1.1(...) - // - // The race: topic.Publish() copies the subscriber list and calls each subscriber in its own - // goroutine. If the subscriber disconnects, the handler returns and Go's HTTP server cleans up - // the response writer. But a Publish goroutine that copied the subscriber list BEFORE - // Unsubscribe may still call sub() AFTER the handler returns. - // - // This test deterministically reproduces the scenario by: - // 1. Subscribing via handleSubscribeHTTP (which registers a sub closure on the topic) - // 2. Copying the subscriber function from the topic (simulating what topic.Publish does) - // 3. Cancelling the subscription and waiting for the handler to fully return - // 4. Calling the copied subscriber function AFTER the handler has returned - // 5. Checking that no write/flush occurred on the (now-invalid) response writer - // - // Without the wlock+closed fix, calling the subscriber after the handler returns writes to - // the closed response writer (which in production causes a nil pointer panic on Flush). - // With the fix, the subscriber sees closed=true and returns without writing. - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + // This test reproduces the panic from https://github.com/binwiederhier/ntfy/issues/338: + // + // panic: runtime error: invalid memory address or nil pointer dereference + // bufio.(*Writer).Flush(...) + // net/http.(*response).Flush(...) + // server.(*Server).handleSubscribeHTTP.func2(...) + // server.(*topic).Publish.func1.1(...) + // + // The race: topic.Publish() copies the subscriber list and calls each subscriber in its own + // goroutine. If the subscriber disconnects, the handler returns and Go's HTTP server cleans up + // the response writer. But a Publish goroutine that copied the subscriber list BEFORE + // Unsubscribe may still call sub() AFTER the handler returns. + // + // This test deterministically reproduces the scenario by: + // 1. Subscribing via handleSubscribeHTTP (which registers a sub closure on the topic) + // 2. Copying the subscriber function from the topic (simulating what topic.Publish does) + // 3. Cancelling the subscription and waiting for the handler to fully return + // 4. Calling the copied subscriber function AFTER the handler has returned + // 5. Checking that no write/flush occurred on the (now-invalid) response writer + // + // Without the wlock+closed fix, calling the subscriber after the handler returns writes to + // the closed response writer (which in production causes a nil pointer panic on Flush). + // With the fix, the subscriber sees closed=true and returns without writing. + t.Parallel() + s := newTestServer(t, newTestConfig(t)) - rw := newClosableResponseWriter() - ctx, cancel := context.WithCancel(context.Background()) - req, err := http.NewRequestWithContext(ctx, "GET", "/mytopic/json", nil) - require.Nil(t, err) - req.RemoteAddr = "9.9.9.9:1234" + rw := newClosableResponseWriter() + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, "GET", "/mytopic/json", nil) + require.Nil(t, err) + req.RemoteAddr = "9.9.9.9:1234" - // Start the subscribe handler (blocks until context is cancelled) - handlerDone := make(chan struct{}) - go func() { - s.handle(rw, req) - close(handlerDone) - }() - time.Sleep(100 * time.Millisecond) // Wait for subscription to be registered + // Start the subscribe handler (blocks until context is cancelled) + handlerDone := make(chan struct{}) + go func() { + s.handle(rw, req) + close(handlerDone) + }() + time.Sleep(100 * time.Millisecond) // Wait for subscription to be registered - // Grab a copy of the subscriber function from the topic, exactly as topic.Publish() does - // via subscribersCopy(). This must happen BEFORE cancel/Unsubscribe removes the subscriber. - s.mu.RLock() - tp := s.topics["mytopic"] - s.mu.RUnlock() - require.NotNil(t, tp) - subscribersCopy := tp.subscribersCopy() - require.Equal(t, 1, len(subscribersCopy)) + // Grab a copy of the subscriber function from the topic, exactly as topic.Publish() does + // via subscribersCopy(). This must happen BEFORE cancel/Unsubscribe removes the subscriber. + s.mu.RLock() + tp := s.topics["mytopic"] + s.mu.RUnlock() + require.NotNil(t, tp) + subscribersCopy := tp.subscribersCopy() + require.Equal(t, 1, len(subscribersCopy)) - var copiedSub subscriber - for _, sub := range subscribersCopy { - copiedSub = sub.subscriber - } + var copiedSub subscriber + for _, sub := range subscribersCopy { + copiedSub = sub.subscriber + } - // Cancel the subscription and wait for the handler to fully return. - // At this point, the deferred cleanup in handleSubscribeHTTP runs: - // - With fix: wlock.Lock() waits for in-flight sub(), sets closed=true, wlock.Unlock() - // - Without fix: nothing prevents future sub() calls from writing - cancel() - <-handlerDone + // Cancel the subscription and wait for the handler to fully return. + // At this point, the deferred cleanup in handleSubscribeHTTP runs: + // - With fix: wlock.Lock() waits for in-flight sub(), sets closed=true, wlock.Unlock() + // - Without fix: nothing prevents future sub() calls from writing + cancel() + <-handlerDone - // Simulate Go's HTTP server cleaning up the response writer after the handler returns. - // In production, this is finishRequest() which nils out the bufio.Writer. - rw.Close() + // Simulate Go's HTTP server cleaning up the response writer after the handler returns. + // In production, this is finishRequest() which nils out the bufio.Writer. + rw.Close() - // Now call the copied subscriber function, simulating a straggler Publish goroutine - // that copied the subscriber list before Unsubscribe ran. In production, this is exactly - // how the panic occurs: the goroutine spawned by topic.Publish calls sub() after the - // handler has already returned and Go has cleaned up the response writer. - v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("9.9.9.9"), nil) - msg := newDefaultMessage("mytopic", "straggler message") - _ = copiedSub(v, msg) + // Now call the copied subscriber function, simulating a straggler Publish goroutine + // that copied the subscriber list before Unsubscribe ran. In production, this is exactly + // how the panic occurs: the goroutine spawned by topic.Publish calls sub() after the + // handler has already returned and Go has cleaned up the response writer. + v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("9.9.9.9"), nil) + msg := newDefaultMessage("mytopic", "straggler message") + _ = copiedSub(v, msg) - require.False(t, rw.wroteAfterClose.Load(), - "sub() wrote to the response writer after the handler returned; "+ - "in production this causes a nil pointer panic in bufio.(*Writer).Flush()") + require.False(t, rw.wroteAfterClose.Load(), + "sub() wrote to the response writer after the handler returned; "+ + "in production this causes a nil pointer panic in bufio.(*Writer).Flush()") + }) } func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) { - // Test that handleError does not call WriteHeader for WebSocket errors wrapped - // with errWebSocketPostUpgrade (indicating the connection was hijacked) - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + // Test that handleError does not call WriteHeader for WebSocket errors wrapped + // with errWebSocketPostUpgrade (indicating the connection was hijacked) + s := newTestServer(t, newTestConfig(t)) - // Create a WebSocket upgrade request - r, _ := http.NewRequest("GET", "/mytopic/ws", nil) - r.Header.Set("Upgrade", "websocket") - r.Header.Set("Connection", "Upgrade") - v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("1.2.3.4"), nil) + // Create a WebSocket upgrade request + r, _ := http.NewRequest("GET", "/mytopic/ws", nil) + r.Header.Set("Upgrade", "websocket") + r.Header.Set("Connection", "Upgrade") + v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("1.2.3.4"), nil) - // Test post-upgrade errors wrapped with errWebSocketPostUpgrade (should NOT call WriteHeader) - postUpgradeErr := &errWebSocketPostUpgrade{errors.New("websocket: close 1000 (normal)")} - mock := newMockResponseWriter() - s.handleError(mock, r, v, postUpgradeErr) - require.False(t, mock.writeHeaderHit, "WriteHeader should not be called for post-upgrade errors") - - // Test pre-upgrade errors (should call WriteHeader) - preUpgradeErrors := []error{ - errHTTPBadRequestWebSocketsUpgradeHeaderMissing, - errHTTPTooManyRequestsLimitSubscriptions, - errHTTPInternalError, - } - for _, err := range preUpgradeErrors { + // Test post-upgrade errors wrapped with errWebSocketPostUpgrade (should NOT call WriteHeader) + postUpgradeErr := &errWebSocketPostUpgrade{errors.New("websocket: close 1000 (normal)")} mock := newMockResponseWriter() - s.handleError(mock, r, v, err) - require.True(t, mock.writeHeaderHit, "WriteHeader should be called for error: %s", err.Error()) - } + s.handleError(mock, r, v, postUpgradeErr) + require.False(t, mock.writeHeaderHit, "WriteHeader should not be called for post-upgrade errors") + + // Test pre-upgrade errors (should call WriteHeader) + preUpgradeErrors := []error{ + errHTTPBadRequestWebSocketsUpgradeHeaderMissing, + errHTTPTooManyRequestsLimitSubscriptions, + errHTTPInternalError, + } + for _, err := range preUpgradeErrors { + mock := newMockResponseWriter() + s.handleError(mock, r, v, err) + require.True(t, mock.writeHeaderHit, "WriteHeader should be called for error: %s", err.Error()) + } + }) } diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 9b6dcff5..29768b53 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -14,217 +14,224 @@ import ( ) func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { - var called, verified atomic.Bool - var code atomic.Pointer[string] - twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - if r.URL.Path == "/v2/Services/VA1234567890/Verifications" { - if code.Load() != nil { + forEachBackend(t, func(t *testing.T) { + var called, verified atomic.Bool + var code atomic.Pointer[string] + twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + if r.URL.Path == "/v2/Services/VA1234567890/Verifications" { + if code.Load() != nil { + t.Fatal("Should be only called once") + } + require.Equal(t, "Channel=sms&To=%2B12223334444", string(body)) + code.Store(util.String("123456")) + } else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" { + if verified.Load() { + t.Fatal("Should be only called once") + } + require.Equal(t, "Code=123456&To=%2B12223334444", string(body)) + verified.Store(true) + } else { + t.Fatal("Unexpected path:", r.URL.Path) + } + })) + defer twilioVerifyServer.Close() + twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { t.Fatal("Should be only called once") } - require.Equal(t, "Channel=sms&To=%2B12223334444", string(body)) - code.Store(util.String("123456")) - } else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" { - if verified.Load() { - t.Fatal("Should be only called once") - } - require.Equal(t, "Code=123456&To=%2B12223334444", string(body)) - verified.Store(true) - } else { - t.Fatal("Unexpected path:", r.URL.Path) - } - })) - defer twilioVerifyServer.Close() - twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if called.Load() { - t.Fatal("Should be only called once") - } - body, err := io.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioCallsServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioVerifyBaseURL = twilioVerifyServer.URL + c.TwilioCallsBaseURL = twilioCallsServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + c.TwilioVerifyService = "VA1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioCallsServer.Close() - c := newTestConfigWithAuthFile(t) - c.TwilioVerifyBaseURL = twilioVerifyServer.URL - c.TwilioCallsBaseURL = twilioCallsServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - c.TwilioVerifyService = "VA1234567890" - s := newTestServer(t, c) + // Send verification code for phone number + response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + waitFor(t, func() bool { + return *code.Load() == "123456" + }) - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - u, err := s.userManager.User("phil") - require.Nil(t, err) + // Add phone number with code + response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + waitFor(t, func() bool { + return verified.Load() + }) + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Equal(t, 1, len(phoneNumbers)) + require.Equal(t, "+12223334444", phoneNumbers[0]) - // Send verification code for phone number - response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - waitFor(t, func() bool { - return *code.Load() == "123456" - }) + // Do the thing + response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "yes", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) - // Add phone number with code - response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - waitFor(t, func() bool { - return verified.Load() - }) - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - require.Nil(t, err) - require.Equal(t, 1, len(phoneNumbers)) - require.Equal(t, "+12223334444", phoneNumbers[0]) + // Remove the phone number + response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) - // Do the thing - response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "yes", + // Verify the phone number is gone from the DB + phoneNumbers, err = s.userManager.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Equal(t, 0, len(phoneNumbers)) }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() - }) - - // Remove the phone number - response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - - // Verify the phone number is gone from the DB - phoneNumbers, err = s.userManager.PhoneNumbers(u.ID) - require.Nil(t, err) - require.Equal(t, 0, len(phoneNumbers)) } func TestServer_Twilio_Call_Success(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if called.Load() { - t.Fatal("Should be only called once") - } - body, err := io.ReadAll(r.Body) + forEachBackend(t, func(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioServer.Close() + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = twilioServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - u, err := s.userManager.User("phil") - require.Nil(t, err) - require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) - - // Do the thing - response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "+11122233344", - }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) }) } func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if called.Load() { - t.Fatal("Should be only called once") - } - body, err := io.ReadAll(r.Body) + forEachBackend(t, func(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioServer.Close() + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = twilioServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - u, err := s.userManager.User("phil") - require.Nil(t, err) - require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) - - // Do the thing - response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "yes", // <<<------ - }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "yes", // <<<------ + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) }) } func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if called.Load() { - t.Fatal("Should be only called once") - } - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioServer.Close() + forEachBackend(t, func(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = twilioServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - c.TwilioCallFormat = template.Must(template.New("twiml").Parse(` + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + c.TwilioCallFormat = template.Must(template.New("twiml").Parse(` @@ -240,88 +247,97 @@ func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { Auf Wiederhören. `)) - s := newTestServer(t, c) + s := newTestServer(t, c) - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - u, err := s.userManager.User("phil") - require.Nil(t, err) - require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) - // Do the thing - response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "+11122233344", - }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) }) } func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = "http://dummy.invalid" - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = "http://dummy.invalid" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - // Do the thing - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "+11122233344", + // Do the thing + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code) }) - require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code) } func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = "https://127.0.0.1" - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = "https://127.0.0.1" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-call": "+invalid", + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+invalid", + }) + require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code) }) - require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code) } func TestServer_Twilio_Call_Anonymous(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = "https://127.0.0.1" - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) + forEachBackend(t, func(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = "https://127.0.0.1" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + s := newTestServer(t, c) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-call": "+123123", + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+123123", + }) + require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code) }) - require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code) } func TestServer_Twilio_Call_Unconfigured(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-call": "+1234", + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+1234", + }) + require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code) }) - require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code) } diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go index 7bea9460..a79f0369 100644 --- a/server/server_webpush_test.go +++ b/server/server_webpush_test.go @@ -26,236 +26,262 @@ const ( ) func TestServer_WebPush_Enabled(t *testing.T) { - conf := newTestConfig(t) - conf.WebRoot = "" // Disable web app - s := newTestServer(t, conf) + forEachBackend(t, func(t *testing.T) { + conf := newTestConfig(t) + conf.WebRoot = "" // Disable web app + s := newTestServer(t, conf) - rr := request(t, s, "GET", "/manifest.webmanifest", "", nil) - require.Equal(t, 404, rr.Code) + rr := request(t, s, "GET", "/manifest.webmanifest", "", nil) + require.Equal(t, 404, rr.Code) - conf2 := newTestConfig(t) - s2 := newTestServer(t, conf2) + conf2 := newTestConfig(t) + s2 := newTestServer(t, conf2) - rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil) - require.Equal(t, 404, rr.Code) + rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil) + require.Equal(t, 404, rr.Code) - conf3 := newTestConfigWithWebPush(t) - s3 := newTestServer(t, conf3) + conf3 := newTestConfigWithWebPush(t) + s3 := newTestServer(t, conf3) - rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil) - require.Equal(t, 200, rr.Code) - require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type")) + rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil) + require.Equal(t, 200, rr.Code) + require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type")) + }) } func TestServer_WebPush_Disabled(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) - require.Equal(t, 404, response.Code) + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) + require.Equal(t, 404, response.Code) + }) } func TestServer_WebPush_TopicAdd(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) - response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) - subs, err := s.webPush.SubscriptionsForTopic("test-topic") - require.Nil(t, err) + subs, err := s.webPush.SubscriptionsForTopic("test-topic") + require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) - require.Equal(t, subs[0].P256dh, "p256dh-key") - require.Equal(t, subs[0].Auth, "auth-key") - require.Equal(t, subs[0].UserID, "") + require.Len(t, subs, 1) + require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) + require.Equal(t, subs[0].P256dh, "p256dh-key") + require.Equal(t, subs[0].Auth, "auth-key") + require.Equal(t, subs[0].UserID, "") + }) } func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) - response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil) - require.Equal(t, 400, response.Code) - require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String()) + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil) + require.Equal(t, 400, response.Code) + require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String()) + }) } func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) - topicList := make([]string, 51) - for i := range topicList { - topicList[i] = util.RandomString(5) - } + topicList := make([]string, 51) + for i := range topicList { + topicList[i] = util.RandomString(5) + } - response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil) - require.Equal(t, 400, response.Code) - require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String()) + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil) + require.Equal(t, 400, response.Code) + require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String()) + }) } func TestServer_WebPush_TopicUnsubscribe(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) - addSubscription(t, s, testWebPushEndpoint, "test-topic") - requireSubscriptionCount(t, s, "test-topic", 1) + addSubscription(t, s, testWebPushEndpoint, "test-topic") + requireSubscriptionCount(t, s, "test-topic", 1) - response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) - requireSubscriptionCount(t, s, "test-topic", 0) + requireSubscriptionCount(t, s, "test-topic", 0) + }) } func TestServer_WebPush_Delete(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) - addSubscription(t, s, testWebPushEndpoint, "test-topic") - requireSubscriptionCount(t, s, "test-topic", 1) + addSubscription(t, s, testWebPushEndpoint, "test-topic") + requireSubscriptionCount(t, s, "test-topic", 1) - response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) - requireSubscriptionCount(t, s, "test-topic", 0) + requireSubscriptionCount(t, s, "test-topic", 0) + }) } func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) { - config := configureAuth(t, newTestConfigWithWebPush(t)) - config.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, config) + forEachBackend(t, func(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + config.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, config) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) - response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + subs, err := s.webPush.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + require.Len(t, subs, 1) + require.True(t, strings.HasPrefix(subs[0].UserID, "u_")) }) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"success":true}`+"\n", response.Body.String()) - - subs, err := s.webPush.SubscriptionsForTopic("test-topic") - require.Nil(t, err) - require.Len(t, subs, 1) - require.True(t, strings.HasPrefix(subs[0].UserID, "u_")) } func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) { - config := configureAuth(t, newTestConfigWithWebPush(t)) - config.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, config) + forEachBackend(t, func(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + config.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, config) - response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) - require.Equal(t, 403, response.Code) + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) + require.Equal(t, 403, response.Code) - requireSubscriptionCount(t, s, "test-topic", 0) + requireSubscriptionCount(t, s, "test-topic", 0) + }) } func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) { - config := configureAuth(t, newTestConfigWithWebPush(t)) - s := newTestServer(t, config) + forEachBackend(t, func(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + s := newTestServer(t, config) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) - response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + requireSubscriptionCount(t, s, "test-topic", 1) + + request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + // should've been deleted with the account + requireSubscriptionCount(t, s, "test-topic", 0) }) - - require.Equal(t, 200, response.Code) - require.Equal(t, `{"success":true}`+"\n", response.Body.String()) - - requireSubscriptionCount(t, s, "test-topic", 1) - - request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - // should've been deleted with the account - requireSubscriptionCount(t, s, "test-topic", 0) } func TestServer_WebPush_Publish(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) - var received atomic.Bool - pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/push-receive", r.URL.Path) - require.Equal(t, "high", r.Header.Get("Urgency")) - require.Equal(t, "", r.Header.Get("Topic")) - received.Store(true) - })) - defer pushService.Close() + var received atomic.Bool + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/push-receive", r.URL.Path) + require.Equal(t, "high", r.Header.Get("Urgency")) + require.Equal(t, "", r.Header.Get("Topic")) + received.Store(true) + })) + defer pushService.Close() - addSubscription(t, s, pushService.URL+"/push-receive", "test-topic") - request(t, s, "POST", "/test-topic", "web push test", nil) + addSubscription(t, s, pushService.URL+"/push-receive", "test-topic") + request(t, s, "POST", "/test-topic", "web push test", nil) - waitFor(t, func() bool { - return received.Load() + waitFor(t, func() bool { + return received.Load() + }) }) } func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) - var received atomic.Bool - pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := io.ReadAll(r.Body) - require.Nil(t, err) - w.WriteHeader(http.StatusGone) - received.Store(true) - })) - defer pushService.Close() + var received atomic.Bool + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + w.WriteHeader(http.StatusGone) + received.Store(true) + })) + defer pushService.Close() - addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc") - requireSubscriptionCount(t, s, "test-topic", 1) - requireSubscriptionCount(t, s, "test-topic-abc", 1) + addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc") + requireSubscriptionCount(t, s, "test-topic", 1) + requireSubscriptionCount(t, s, "test-topic-abc", 1) - request(t, s, "POST", "/test-topic", "web push test", nil) + request(t, s, "POST", "/test-topic", "web push test", nil) - waitFor(t, func() bool { - return received.Load() + waitFor(t, func() bool { + return received.Load() + }) + + // Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint + + requireSubscriptionCount(t, s, "test-topic", 0) + requireSubscriptionCount(t, s, "test-topic-abc", 0) }) - - // Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint - - requireSubscriptionCount(t, s, "test-topic", 0) - requireSubscriptionCount(t, s, "test-topic-abc", 0) } func TestServer_WebPush_Expiry(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) - var received atomic.Bool + var received atomic.Bool - pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := io.ReadAll(r.Body) - require.Nil(t, err) - w.WriteHeader(200) - w.Write([]byte(``)) - received.Store(true) - })) - defer pushService.Close() + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + w.WriteHeader(200) + w.Write([]byte(``)) + received.Store(true) + })) + defer pushService.Close() - endpoint := pushService.URL + "/push-receive" - addSubscription(t, s, endpoint, "test-topic") - requireSubscriptionCount(t, s, "test-topic", 1) + endpoint := pushService.URL + "/push-receive" + addSubscription(t, s, endpoint, "test-topic") + requireSubscriptionCount(t, s, "test-topic", 1) - require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix())) + require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix())) - s.pruneAndNotifyWebPushSubscriptions() - requireSubscriptionCount(t, s, "test-topic", 1) + s.pruneAndNotifyWebPushSubscriptions() + requireSubscriptionCount(t, s, "test-topic", 1) - waitFor(t, func() bool { - return received.Load() - }) + waitFor(t, func() bool { + return received.Load() + }) - require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix())) + require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix())) - s.pruneAndNotifyWebPushSubscriptions() - waitFor(t, func() bool { - subs, err := s.webPush.SubscriptionsForTopic("test-topic") - require.Nil(t, err) - return len(subs) == 0 + s.pruneAndNotifyWebPushSubscriptions() + waitFor(t, func() bool { + subs, err := s.webPush.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + return len(subs) == 0 + }) }) } @@ -285,7 +311,9 @@ func newTestConfigWithWebPush(t *testing.T) *Config { conf := newTestConfig(t) privateKey, publicKey, err := webpush.GenerateVAPIDKeys() require.Nil(t, err) - conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db") + if conf.DatabaseURL == "" { + conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db") + } conf.WebPushEmailAddress = "testing@example.com" conf.WebPushPrivateKey = privateKey conf.WebPushPublicKey = publicKey From 305e3fc9af7e8256925e900dd463d4121fee2f47 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 19 Feb 2026 21:36:09 -0500 Subject: [PATCH 202/548] DB conn to 25 --- message/store_postgres.go | 1 + server/server_account_test.go | 86 ++-- server/server_admin_test.go | 48 +-- server/server_firebase_test.go | 2 +- server/server_manager_test.go | 4 +- server/server_payments_test.go | 40 +- server/server_test.go | 737 ++++++++++++++++----------------- server/server_twilio_test.go | 32 +- server/server_webpush_test.go | 60 +-- server/smtp_server_test.go | 2 +- user/store_postgres.go | 1 + webpush/store_postgres.go | 1 + 12 files changed, 503 insertions(+), 511 deletions(-) diff --git a/message/store_postgres.go b/message/store_postgres.go index 46c26bad..7ddb2464 100644 --- a/message/store_postgres.go +++ b/message/store_postgres.go @@ -110,6 +110,7 @@ func NewPostgresStore(dsn string, batchSize int, batchTimeout time.Duration) (St if err != nil { return nil, err } + db.SetMaxOpenConns(25) if err := db.Ping(); err != nil { return nil, err } diff --git a/server/server_account_test.go b/server/server_account_test.go index d573e06e..3e2f94c9 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -15,8 +15,8 @@ import ( ) func TestAccount_Signup_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) defer s.closeDatabases() @@ -54,8 +54,8 @@ func TestAccount_Signup_Success(t *testing.T) { } func TestAccount_Signup_UserExists(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) defer s.closeDatabases() @@ -70,8 +70,8 @@ func TestAccount_Signup_UserExists(t *testing.T) { } func TestAccount_Signup_LimitReached(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) defer s.closeDatabases() @@ -87,8 +87,8 @@ func TestAccount_Signup_LimitReached(t *testing.T) { } func TestAccount_Signup_AsUser(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) defer s.closeDatabases() @@ -111,8 +111,8 @@ func TestAccount_Signup_AsUser(t *testing.T) { } func TestAccount_Signup_Disabled(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = false s := newTestServer(t, conf) defer s.closeDatabases() @@ -124,8 +124,8 @@ func TestAccount_Signup_Disabled(t *testing.T) { } func TestAccount_Signup_Rate_Limit(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) @@ -140,8 +140,8 @@ func TestAccount_Signup_Rate_Limit(t *testing.T) { } func TestAccount_Get_Anonymous(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.VisitorRequestLimitReplenish = 86 * time.Second conf.VisitorEmailLimitReplenish = time.Hour conf.VisitorAttachmentTotalSizeLimit = 5123 @@ -185,8 +185,8 @@ func TestAccount_Get_Anonymous(t *testing.T) { } func TestAccount_ChangeSettings(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -216,8 +216,8 @@ func TestAccount_ChangeSettings(t *testing.T) { } func TestAccount_Subscription_AddUpdateDelete(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -269,8 +269,8 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) { } func TestAccount_ChangePassword(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.AuthUsers = []*user.User{ {Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass } @@ -314,8 +314,8 @@ func TestAccount_ChangePassword(t *testing.T) { } func TestAccount_ChangePassword_NoAccount(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil) @@ -324,9 +324,9 @@ func TestAccount_ChangePassword_NoAccount(t *testing.T) { } func TestAccount_ExtendToken(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfigWithAuthFile(t)) + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -363,8 +363,8 @@ func TestAccount_ExtendToken(t *testing.T) { } func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -378,8 +378,8 @@ func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) { } func TestAccount_DeleteToken(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -420,8 +420,8 @@ func TestAccount_DeleteToken(t *testing.T) { } func TestAccount_Delete_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) @@ -451,8 +451,8 @@ func TestAccount_Delete_Success(t *testing.T) { } func TestAccount_Delete_Not_Allowed(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) @@ -474,8 +474,8 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) { } func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) @@ -490,8 +490,8 @@ func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) { } func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true s := newTestServer(t, conf) @@ -544,8 +544,8 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { } func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true conf.EnableReservations = true conf.TwilioAccount = "dummy" @@ -632,8 +632,8 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { } func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) conf.AuthDefault = user.PermissionReadWrite conf.EnableSignup = true s := newTestServer(t, conf) @@ -668,9 +668,9 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) { } func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - conf := newTestConfigWithAuthFile(t) + conf := newTestConfigWithAuthFile(t, databaseURL) conf.AuthDefault = user.PermissionReadWrite s := newTestServer(t, conf) @@ -760,7 +760,7 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { } /*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + conf := newTestConfigWithAuthFile(t, databaseURL) conf.AuthDefault = user.PermissionReadWrite conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond s := newTestServer(t, conf) diff --git a/server/server_admin_test.go b/server/server_admin_test.go index 131cd145..5fb2c1d2 100644 --- a/server/server_admin_test.go +++ b/server/server_admin_test.go @@ -11,8 +11,8 @@ import ( ) func TestVersion_Admin(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.BuildVersion = "1.2.3" c.BuildCommit = "abcdef0" c.BuildDate = "2026-02-08T00:00:00Z" @@ -48,8 +48,8 @@ func TestVersion_Admin(t *testing.T) { } func TestUser_AddRemove(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() // Create admin, tier @@ -106,8 +106,8 @@ func TestUser_AddRemove(t *testing.T) { } func TestUser_AddWithPasswordHash(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() // Create admin @@ -137,8 +137,8 @@ func TestUser_AddWithPasswordHash(t *testing.T) { } func TestUser_ChangeUserPassword(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() // Create admin @@ -177,8 +177,8 @@ func TestUser_ChangeUserPassword(t *testing.T) { } func TestUser_ChangeUserTier(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() // Create admin, tier @@ -219,8 +219,8 @@ func TestUser_ChangeUserTier(t *testing.T) { } func TestUser_ChangeUserPasswordAndTier(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() // Create admin, tier @@ -273,8 +273,8 @@ func TestUser_ChangeUserPasswordAndTier(t *testing.T) { } func TestUser_ChangeUserPasswordWithHash(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() // Create admin @@ -307,8 +307,8 @@ func TestUser_ChangeUserPasswordWithHash(t *testing.T) { } func TestUser_DontChangeAdminPassword(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() // Create admin @@ -324,8 +324,8 @@ func TestUser_DontChangeAdminPassword(t *testing.T) { } func TestUser_AddRemove_Failures(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) defer s.closeDatabases() // Create admin @@ -365,8 +365,8 @@ func TestUser_AddRemove_Failures(t *testing.T) { } func TestAccess_AllowReset(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) defer s.closeDatabases() @@ -408,8 +408,8 @@ func TestAccess_AllowReset(t *testing.T) { } func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) defer s.closeDatabases() @@ -426,8 +426,8 @@ func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) { } func TestAccess_AllowReset_KillConnection(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) defer s.closeDatabases() diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index ab4b494c..a4c8e0b8 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -345,7 +345,7 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) { func TestToFirebaseSender_Abuse(t *testing.T) { sender := &testFirebaseSender{allowed: 2} client := newFirebaseClient(sender, &testAuther{}) - visitor := newVisitor(newTestConfig(t), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil) + visitor := newVisitor(newTestConfig(t, ""), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil) require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"})) require.Equal(t, 1, len(sender.Messages())) diff --git a/server/server_manager_test.go b/server/server_manager_test.go index 1760737b..02454698 100644 --- a/server/server_manager_test.go +++ b/server/server_manager_test.go @@ -6,9 +6,9 @@ import ( ) func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // Tests that the manager runs without attachment-cache-dir set, see #617 - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.AttachmentCacheDir = "" s := newTestServer(t, c) diff --git a/server/server_payments_test.go b/server/server_payments_test.go index 10c2a9b7..64c1affc 100644 --- a/server/server_payments_test.go +++ b/server/server_payments_test.go @@ -21,11 +21,11 @@ import ( ) func TestPayments_Tiers(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" c.VisitorRequestLimitReplenish = 12 * time.Hour @@ -133,11 +133,11 @@ func TestPayments_Tiers(t *testing.T) { } func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" s := newTestServer(t, c) @@ -168,11 +168,11 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) { } func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" s := newTestServer(t, c) @@ -214,11 +214,11 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) { } func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.EnableSignup = true c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" @@ -261,7 +261,7 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) { } func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // This test is too overloaded, but it's also a great end-to-end a test. // // It tests: @@ -273,7 +273,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" c.VisitorRequestLimitBurst = 5 @@ -428,7 +428,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes } func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() // This tests incoming webhooks from Stripe to update a subscription: @@ -439,7 +439,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" s := newTestServer(t, c) @@ -559,7 +559,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( } func TestPayments_Webhook_Subscription_Deleted(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // This tests incoming webhooks from Stripe to delete a subscription. It verifies that the database is // updated (all Stripe fields are deleted, and the tier is removed). // @@ -568,7 +568,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) { stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" s := newTestServer(t, c) @@ -626,11 +626,11 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) { } func TestPayments_Subscription_Update_Different_Tier(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" s := newTestServer(t, c) @@ -692,11 +692,11 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) { } func TestPayments_Subscription_Delete_At_Period_End(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" s := newTestServer(t, c) @@ -725,11 +725,11 @@ func TestPayments_Subscription_Delete_At_Period_End(t *testing.T) { } func TestPayments_CreatePortalSession(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { stripeMock := &testStripeAPI{} defer stripeMock.AssertExpectations(t) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" s := newTestServer(t, c) diff --git a/server/server_test.go b/server/server_test.go index 2a8930c9..9d27f361 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -40,8 +40,8 @@ func TestMain(m *testing.M) { } func TestServer_PublishAndPoll(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response1 := request(t, s, "PUT", "/mytopic", "my first message", nil) msg1 := toMessage(t, response1.Body.String()) @@ -76,9 +76,9 @@ func TestServer_PublishAndPoll(t *testing.T) { } func TestServer_PublishWithFirebase(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) response := request(t, s, "PUT", "/mytopic", "my first message", nil) @@ -95,9 +95,9 @@ func TestServer_PublishWithFirebase(t *testing.T) { } func TestServer_PublishWithoutFirebase(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) response := request(t, s, "PUT", "/mytopic", "my first message", map[string]string{ @@ -113,7 +113,7 @@ func TestServer_PublishWithoutFirebase(t *testing.T) { } func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // This tests issue #641, which used to panic before the fix firebaseKeyFile := filepath.Join(t.TempDir(), "firebase.json") @@ -131,7 +131,7 @@ func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) { } ` require.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600)) - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.FirebaseKeyFile = firebaseKeyFile s := newTestServer(t, c) @@ -141,9 +141,9 @@ func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) { } func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.KeepaliveInterval = time.Second s := newTestServer(t, c) @@ -182,9 +182,9 @@ func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { } func TestServer_PublishAndSubscribe(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) subscribeRR := httptest.NewRecorder() subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR) @@ -224,8 +224,8 @@ func TestServer_PublishAndSubscribe(t *testing.T) { } func TestServer_Publish_Disallowed_Topic(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.DisallowedTopics = []string{"about", "time", "this", "got", "added"} s := newTestServer(t, c) @@ -239,8 +239,8 @@ func TestServer_Publish_Disallowed_Topic(t *testing.T) { } func TestServer_StaticSites(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) rr := request(t, s, "GET", "/", "", nil) require.Equal(t, 200, rr.Code) @@ -267,8 +267,8 @@ func TestServer_StaticSites(t *testing.T) { } func TestServer_WebEnabled(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfig(t, databaseURL) conf.WebRoot = "" // Disable web app s := newTestServer(t, conf) @@ -287,7 +287,7 @@ func TestServer_WebEnabled(t *testing.T) { rr = request(t, s, "GET", "/static/css/home.css", "", nil) require.Equal(t, 404, rr.Code) - conf2 := newTestConfig(t) + conf2 := newTestConfig(t, databaseURL) conf2.WebRoot = "/" s2 := newTestServer(t, conf2) @@ -305,8 +305,8 @@ func TestServer_WebEnabled(t *testing.T) { }) } func TestServer_PublishLargeMessage(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.AttachmentCacheDir = "" // Disable attachments s := newTestServer(t, c) @@ -317,8 +317,8 @@ func TestServer_PublishLargeMessage(t *testing.T) { } func TestServer_PublishPriority(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) for prio := 1; prio <= 5; prio++ { response := request(t, s, "GET", fmt.Sprintf("/mytopic/publish?priority=%d", prio), fmt.Sprintf("priority %d", prio), nil) @@ -350,8 +350,8 @@ func TestServer_PublishPriority(t *testing.T) { } func TestServer_PublishPriority_SpecialHTTPHeader(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "Priority": "u=4", @@ -373,18 +373,18 @@ func TestServer_PublishPriority_SpecialHTTPHeader(t *testing.T) { } func TestServer_PublishGETOnlyOneTopic(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // This tests a bug that allowed publishing topics with a comma in the name (no ticket) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "GET", "/mytopic,mytopic2/publish?m=hi", "", nil) require.Equal(t, 404, response.Code) }) } func TestServer_PublishNoCache(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "this message is not cached", map[string]string{ "Cache": "no", @@ -401,9 +401,9 @@ func TestServer_PublishNoCache(t *testing.T) { } func TestServer_PublishAt(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ "In": "1h", @@ -437,9 +437,9 @@ func TestServer_PublishAt(t *testing.T) { } func TestServer_PublishAt_FromUser(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfigWithAuthFile(t)) + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ @@ -476,8 +476,8 @@ func TestServer_PublishAt_FromUser(t *testing.T) { } func TestServer_PublishAt_Expires(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ "In": "2 days", @@ -490,8 +490,8 @@ func TestServer_PublishAt_Expires(t *testing.T) { } func TestServer_PublishAtWithCacheError(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ "Cache": "no", @@ -503,8 +503,8 @@ func TestServer_PublishAtWithCacheError(t *testing.T) { } func TestServer_PublishAtTooShortDelay(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ "In": "1s", @@ -514,8 +514,8 @@ func TestServer_PublishAtTooShortDelay(t *testing.T) { } func TestServer_PublishAtTooLongDelay(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ "In": "99999999h", }) @@ -524,8 +524,8 @@ func TestServer_PublishAtTooLongDelay(t *testing.T) { } func TestServer_PublishAtInvalidDelay(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic?delay=INVALID", "a message", nil) err := toHTTPError(t, response.Body.String()) require.Equal(t, 400, response.Code) @@ -534,8 +534,8 @@ func TestServer_PublishAtInvalidDelay(t *testing.T) { } func TestServer_PublishAtTooLarge(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic?x-in=99999h", "a message", nil) err := toHTTPError(t, response.Body.String()) require.Equal(t, 400, response.Code) @@ -544,8 +544,8 @@ func TestServer_PublishAtTooLarge(t *testing.T) { } func TestServer_PublishAtAndPrune(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ "In": "1h", @@ -563,8 +563,8 @@ func TestServer_PublishAtAndPrune(t *testing.T) { } func TestServer_PublishAndMultiPoll(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic1", "message 1", nil) msg := toMessage(t, response.Body.String()) @@ -595,8 +595,8 @@ func TestServer_PublishAndMultiPoll(t *testing.T) { } func TestServer_PublishWithNopCache(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.CacheDuration = 0 s := newTestServer(t, c) @@ -620,9 +620,9 @@ func TestServer_PublishWithNopCache(t *testing.T) { } func TestServer_PublishAndPollSince(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) request(t, s, "PUT", "/mytopic", "test 1", nil) time.Sleep(1100 * time.Millisecond) @@ -662,8 +662,8 @@ func newMessageWithTimestamp(topic, msg string, timestamp int64) *model.Message } func TestServer_PollSinceID_MultipleTopics(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 1", 1655740277))) markerMessage := newMessageWithTimestamp("mytopic2", "test 2", 1655740283) @@ -688,8 +688,8 @@ func TestServer_PollSinceID_MultipleTopics(t *testing.T) { } func TestServer_PollSinceID_MultipleTopics_IDDoesNotMatch(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289))) require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293))) @@ -707,8 +707,8 @@ func TestServer_PollSinceID_MultipleTopics_IDDoesNotMatch(t *testing.T) { } func TestServer_PublishViaGET(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "GET", "/mytopic/trigger", "", nil) msg := toMessage(t, response.Body.String()) @@ -727,8 +727,8 @@ func TestServer_PublishViaGET(t *testing.T) { } func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "", map[string]string{ "Message": "Line 1\\nLine 2", @@ -740,8 +740,8 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { } func TestServer_PublishInvalidTopic(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) s.smtpSender = &testMailer{} response := request(t, s, "PUT", "/docs", "fail", nil) require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) @@ -749,8 +749,8 @@ func TestServer_PublishInvalidTopic(t *testing.T) { } func TestServer_PublishWithSIDInPath(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic/sid", "message", nil) msg := toMessage(t, response.Body.String()) @@ -760,8 +760,8 @@ func TestServer_PublishWithSIDInPath(t *testing.T) { } func TestServer_PublishWithSIDInHeader(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", "message", map[string]string{ "sid": "sid", @@ -773,8 +773,8 @@ func TestServer_PublishWithSIDInHeader(t *testing.T) { } func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{ "sid": "sid2", @@ -786,8 +786,8 @@ func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) { } func TestServer_PublishWithSIDInQuery(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil) msg := toMessage(t, response.Body.String()) @@ -797,8 +797,8 @@ func TestServer_PublishWithSIDInQuery(t *testing.T) { } func TestServer_PublishWithSIDViaGet(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil) msg := toMessage(t, response.Body.String()) @@ -808,8 +808,8 @@ func TestServer_PublishWithSIDViaGet(t *testing.T) { } func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}` response := request(t, s, "PUT", "/", body, nil) @@ -822,8 +822,8 @@ func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) { } func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic/.", "message", nil) @@ -832,8 +832,8 @@ func TestServer_PublishWithInvalidSIDInPath(t *testing.T) { } func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", "message", map[string]string{ "X-Sequence-ID": "*&?", @@ -845,8 +845,8 @@ func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) { } func TestServer_PollWithQueryFilters(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic?priority=1&tags=tag1,tag2", "my first message", nil) msg := toMessage(t, response.Body.String()) @@ -916,9 +916,9 @@ func TestServer_PollWithQueryFilters(t *testing.T) { } func TestServer_SubscribeWithQueryFilters(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.KeepaliveInterval = 800 * time.Millisecond s := newTestServer(t, c) @@ -945,8 +945,8 @@ func TestServer_SubscribeWithQueryFilters(t *testing.T) { } func TestServer_Auth_Success_Admin(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) @@ -960,8 +960,8 @@ func TestServer_Auth_Success_Admin(t *testing.T) { } func TestServer_Auth_Success_User(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) @@ -976,8 +976,8 @@ func TestServer_Auth_Success_User(t *testing.T) { } func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) @@ -998,8 +998,8 @@ func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) { } func TestServer_Auth_Fail_InvalidPass(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) @@ -1013,8 +1013,8 @@ func TestServer_Auth_Fail_InvalidPass(t *testing.T) { } func TestServer_Auth_Fail_Unauthorized(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) @@ -1029,8 +1029,8 @@ func TestServer_Auth_Fail_Unauthorized(t *testing.T) { } func TestServer_Auth_Fail_CannotPublish(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionReadWrite // Open by default s := newTestServer(t, c) @@ -1061,8 +1061,8 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) { } func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.VisitorAuthFailureLimitBurst = 10 s := newTestServer(t, c) @@ -1082,8 +1082,8 @@ func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) { } func TestServer_Auth_ViaQuery(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionDenyAll s := newTestServer(t, c) @@ -1100,8 +1100,8 @@ func TestServer_Auth_ViaQuery(t *testing.T) { } func TestServer_Auth_NonBasicHeader(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ "Authorization": "WebPush not-supported", @@ -1121,14 +1121,14 @@ func TestServer_Auth_NonBasicHeader(t *testing.T) { } func TestServer_StatsResetter(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() // This tests the stats resetter for // - an anonymous user // - a user without a tier (treated like the same as the anonymous user) // - a user with a tier - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.VisitorStatsResetTime = time.Now().Add(2 * time.Second) s := newTestServer(t, c) go s.runStatsResetter() @@ -1227,11 +1227,11 @@ func TestServer_StatsResetter(t *testing.T) { } func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // This tests that the messageLimiter (the only fixed limiter) and the emailsLimiter (token bucket) // is reset by the stats resetter - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) s := newTestServer(t, c) s.smtpSender = &testMailer{} @@ -1274,13 +1274,13 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { } func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() // This tests that the daily message quota is prefilled originally from the database, // if the visitor is unknown - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthStatsQueueWriterInterval = 100 * time.Millisecond s := newTestServer(t, c) @@ -1339,8 +1339,8 @@ func (t *testMailer) Count() int { } func TestServer_PublishTooManyRequests_Defaults(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) for i := 0; i < 60; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) require.Equal(t, 200, response.Code) @@ -1351,8 +1351,8 @@ func TestServer_PublishTooManyRequests_Defaults(t *testing.T) { } func TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) overrideRemoteAddr1 := func(r *http.Request) { r.RemoteAddr = "[2001:db8:9999:8888:1::1]:1234" } @@ -1373,8 +1373,8 @@ func TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) { } func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorRequestLimitBurst = 6 c.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes s := newTestServer(t, c) @@ -1398,8 +1398,8 @@ func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) { } func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorRequestLimitBurst = 3 c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() s := newTestServer(t, c) @@ -1411,8 +1411,8 @@ func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) { } func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorRequestLimitBurst = 3 c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")} s := newTestServer(t, c) @@ -1427,8 +1427,8 @@ func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) { } func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorRequestLimitBurst = 10 c.VisitorMessageDailyLimit = 4 c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() @@ -1441,9 +1441,9 @@ func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t } func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.VisitorRequestLimitBurst = 60 c.VisitorRequestLimitReplenish = time.Second s := newTestServer(t, c) @@ -1461,8 +1461,8 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) { } func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) s.smtpSender = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ @@ -1478,9 +1478,9 @@ func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { } func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.VisitorEmailLimitReplenish = 500 * time.Millisecond s := newTestServer(t, c) s.smtpSender = &testMailer{} @@ -1509,8 +1509,8 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { } func TestServer_PublishDelayedEmail_Fail(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) s.smtpSender = &testMailer{} response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ "E-Mail": "test@example.com", @@ -1521,8 +1521,8 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) { } func TestServer_PublishDelayedCall_Fail(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioPhoneNumber = "+1234567890" @@ -1536,8 +1536,8 @@ func TestServer_PublishDelayedCall_Fail(t *testing.T) { } func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ "E-Mail": "test@example.com", }) @@ -1546,9 +1546,9 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { } func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) defer s.messageCache.Close() subFn := func(v *visitor, msg *model.Message) error { @@ -1597,9 +1597,9 @@ func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) { } func TestServer_TopicKeepaliveOnPoll(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Create topic by polling once response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) @@ -1617,8 +1617,8 @@ func TestServer_TopicKeepaliveOnPoll(t *testing.T) { } func TestServer_UnifiedPushDiscovery(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "GET", "/mytopic?up=1", "", nil) require.Equal(t, 200, response.Code) require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String()) @@ -1626,12 +1626,12 @@ func TestServer_UnifiedPushDiscovery(t *testing.T) { } func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { b := make([]byte, 12) // Max length _, err := rand.Read(b) require.Nil(t, err) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Register a UnifiedPush subscriber response := request(t, s, "GET", "/up123456789012/json?poll=1", "", nil) @@ -1659,12 +1659,12 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) { } func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { b := make([]byte, 5000) // Longer than max length _, err := rand.Read(b) require.Nil(t, err) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Register a UnifiedPush subscriber response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) @@ -1684,8 +1684,8 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) { } func TestServer_PublishUnifiedPushText(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) // Register a UnifiedPush subscriber response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) @@ -1702,8 +1702,8 @@ func TestServer_PublishUnifiedPushText(t *testing.T) { } func TestServer_MatrixGateway_Discovery_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) require.Equal(t, 200, response.Code) require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String()) @@ -1711,8 +1711,8 @@ func TestServer_MatrixGateway_Discovery_Success(t *testing.T) { } func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BaseURL = "" s := newTestServer(t, c) response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil) @@ -1723,8 +1723,8 @@ func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) { } func TestServer_MatrixGateway_Push_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) require.Equal(t, 200, response.Code) @@ -1742,8 +1742,8 @@ func TestServer_MatrixGateway_Push_Success(t *testing.T) { } func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorSubscriberRateLimiting = true s := newTestServer(t, c) notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` @@ -1754,8 +1754,8 @@ func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) { } func TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorSubscriberRateLimiting = true s := newTestServer(t, c) notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` @@ -1782,8 +1782,8 @@ func TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing. } func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}` response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) require.Equal(t, 200, response.Code) @@ -1796,8 +1796,8 @@ func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) { } func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) notification := `{"message":"this is not really a Matrix message"}` response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) require.Equal(t, 400, response.Code) @@ -1811,8 +1811,8 @@ func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) { } func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BaseURL = "" s := newTestServer(t, c) notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` @@ -1823,8 +1823,8 @@ func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) { } func TestServer_PublishActions_AndPoll(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{ "Actions": "view, Open portal, https://home.nest.com/; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65", }) @@ -1845,8 +1845,8 @@ func TestServer_PublishActions_AndPoll(t *testing.T) { } func TestServer_PublishMarkdown(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ "Content-Type": "text/markdown", }) @@ -1859,8 +1859,8 @@ func TestServer_PublishMarkdown(t *testing.T) { } func TestServer_PublishMarkdown_QueryParam(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic?md=1", "**make this bold**", nil) require.Equal(t, 200, response.Code) @@ -1871,8 +1871,8 @@ func TestServer_PublishMarkdown_QueryParam(t *testing.T) { } func TestServer_PublishMarkdown_NotMarkdown(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ "Content-Type": "not-markdown", }) @@ -1884,8 +1884,8 @@ func TestServer_PublishMarkdown_NotMarkdown(t *testing.T) { } func TestServer_PublishAsJSON(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` + `"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` + `"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}` @@ -1910,8 +1910,8 @@ func TestServer_PublishAsJSON(t *testing.T) { } func TestServer_PublishAsJSON_Markdown(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"topic":"mytopic","message":"**This is bold**","markdown":true}` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 200, response.Code) @@ -1924,10 +1924,10 @@ func TestServer_PublishAsJSON_Markdown(t *testing.T) { } func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // Publishing as JSON follows a different path. This ensures that rate // limiting works for this endpoint as well - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.VisitorMessageDailyLimit = 3 s := newTestServer(t, c) @@ -1942,10 +1942,10 @@ func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) { } func TestServer_PublishAsJSON_WithEmail(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() mailer := &testMailer{} - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) s.smtpSender = mailer body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` response := request(t, s, "PUT", "/", body, nil) @@ -1960,8 +1960,8 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) { } func TestServer_PublishAsJSON_WithActions(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{ "topic":"mytopic", "message":"A message", @@ -1997,8 +1997,8 @@ func TestServer_PublishAsJSON_WithActions(t *testing.T) { } func TestServer_PublishAsJSON_NoCache(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"topic":"mytopic","message": "this message is not cached","cache":"no"}` response := request(t, s, "PUT", "/", body, nil) msg := toMessage(t, response.Body.String()) @@ -2013,9 +2013,9 @@ func TestServer_PublishAsJSON_NoCache(t *testing.T) { } func TestServer_PublishAsJSON_WithoutFirebase(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) body := `{"topic":"mytopic","message": "my first message","firebase":"no"}` @@ -2030,8 +2030,8 @@ func TestServer_PublishAsJSON_WithoutFirebase(t *testing.T) { } func TestServer_PublishAsJSON_Invalid(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"topic":"mytopic",INVALID` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 400, response.Code) @@ -2039,8 +2039,8 @@ func TestServer_PublishAsJSON_Invalid(t *testing.T) { } func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) s := newTestServer(t, c) // Create tier with certain limits @@ -2077,9 +2077,9 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) { } func TestServer_PublishAttachment(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { content := "text file!" + util.RandomString(4990) // > 4096 - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", content, nil) msg := toMessage(t, response.Body.String()) require.Equal(t, "attachment.txt", msg.Attachment.Name) @@ -2111,8 +2111,8 @@ func TestServer_PublishAttachment(t *testing.T) { } func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BehindProxy = true s := newTestServer(t, c) content := "this is an ATTACHMENT" @@ -2142,8 +2142,8 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { } func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "", map[string]string{ "Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", }) @@ -2164,8 +2164,8 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { } func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", "This is a custom message", map[string]string{ "X-Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", "File": "some file.jpg", @@ -2182,8 +2182,8 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { } func TestServer_PublishAttachmentBadURL(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic?a=not+a+URL", "", nil) err := toHTTPError(t, response.Body.String()) require.Equal(t, 400, response.Code) @@ -2193,9 +2193,9 @@ func TestServer_PublishAttachmentBadURL(t *testing.T) { } func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { content := util.RandomString(5000) // > 4096 - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", content, map[string]string{ "Content-Length": "20000000", }) @@ -2207,9 +2207,9 @@ func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) { } func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { content := util.RandomString(5001) // > 5000, see below - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.AttachmentFileSizeLimit = 5000 s := newTestServer(t, c) response := request(t, s, "PUT", "/mytopic", content, nil) @@ -2221,8 +2221,8 @@ func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing. } func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.AttachmentExpiryDuration = 10 * time.Minute s := newTestServer(t, c) response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), map[string]string{ @@ -2236,8 +2236,8 @@ func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) { } func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorAttachmentTotalSizeLimit = 10000 s := newTestServer(t, c) @@ -2257,11 +2257,11 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t * } func TestServer_PublishAttachmentAndExpire(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() content := util.RandomString(5000) // > 4096 - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.AttachmentExpiryDuration = time.Millisecond // Hack s := newTestServer(t, c) @@ -2288,11 +2288,11 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) { } func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() content := util.RandomString(5000) // > 4096 - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.AttachmentExpiryDuration = time.Millisecond // Hack s := newTestServer(t, c) @@ -2337,10 +2337,10 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) { } func TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { content := util.RandomString(5000) // > 4096 - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.VisitorAttachmentDailyBandwidthLimit = 1000 // Much lower than tier bandwidth! s := newTestServer(t, c) @@ -2376,11 +2376,11 @@ func TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) { } func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { smallFile := util.RandomString(20_000) largeFile := util.RandomString(50_000) - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.AttachmentFileSizeLimit = 20_000 c.VisitorAttachmentTotalSizeLimit = 40_000 s := newTestServer(t, c) @@ -2434,10 +2434,10 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) { } func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { content := util.RandomString(5000) // > 4096 - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads s := newTestServer(t, c) @@ -2463,10 +2463,10 @@ func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) { } func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { content := util.RandomString(5000) // > 4096 - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 500 // 5 successful uploads s := newTestServer(t, c) @@ -2486,11 +2486,11 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) { } func TestServer_PublishAttachmentAndImmediatelyGetItWithCacheTimeout(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // This tests the awkward util.Retry in handleFile: Due to the async persisting of messages, // the message is not immediately available when attempting to download it. - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.CacheBatchTimeout = 500 * time.Millisecond c.CacheBatchSize = 10 s := newTestServer(t, c) @@ -2507,10 +2507,10 @@ func TestServer_PublishAttachmentAndImmediatelyGetItWithCacheTimeout(t *testing. } func TestServer_PublishAttachmentAccountStats(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { content := util.RandomString(4999) // > 4096 - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.AttachmentFileSizeLimit = 5000 c.VisitorAttachmentTotalSizeLimit = 6000 s := newTestServer(t, c) @@ -2534,8 +2534,8 @@ func TestServer_PublishAttachmentAccountStats(t *testing.T) { } func TestServer_Visitor_XForwardedFor_None(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BehindProxy = true s := newTestServer(t, c) r, _ := http.NewRequest("GET", "/bla", nil) @@ -2548,8 +2548,8 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) { } func TestServer_Visitor_XForwardedFor_Single(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BehindProxy = true s := newTestServer(t, c) r, _ := http.NewRequest("GET", "/bla", nil) @@ -2562,8 +2562,8 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) { } func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BehindProxy = true s := newTestServer(t, c) r, _ := http.NewRequest("GET", "/bla", nil) @@ -2576,8 +2576,8 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) { } func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BehindProxy = true c.ProxyForwardedHeader = "X-Client-IP" s := newTestServer(t, c) @@ -2591,8 +2591,8 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) { } func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BehindProxy = true c.ProxyForwardedHeader = "X-Client-IP" s := newTestServer(t, c) @@ -2606,8 +2606,8 @@ func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) { } func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BehindProxy = true c.ProxyForwardedHeader = "Forwarded" c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")} @@ -2622,8 +2622,8 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) { } func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.BehindProxy = true c.ProxyForwardedHeader = "Forwarded" c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:1111::/64")} @@ -2638,10 +2638,10 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) { } func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() count := 50000 - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.TotalTopicLimit = 50001 c.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" s := newTestServer(t, c) @@ -2690,8 +2690,8 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { } func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) s := newTestServer(t, conf) defer s.closeDatabases() @@ -2723,8 +2723,8 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) { } func TestServer_SubscriberRateLimiting_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.VisitorRequestLimitBurst = 3 c.VisitorSubscriberRateLimiting = true s := newTestServer(t, c) @@ -2779,8 +2779,8 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) { } func TestServer_SubscriberRateLimiting_NotWrongTopic(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.VisitorSubscriberRateLimiting = true s := newTestServer(t, c) @@ -2797,8 +2797,8 @@ func TestServer_SubscriberRateLimiting_NotWrongTopic(t *testing.T) { } func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.VisitorRequestLimitBurst = 3 c.VisitorSubscriberRateLimiting = false s := newTestServer(t, c) @@ -2834,8 +2834,8 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) { } func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.VisitorRequestLimitBurst = 3 c.VisitorSubscriberRateLimiting = true s := newTestServer(t, c) @@ -2860,8 +2860,8 @@ func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) { } func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorRequestLimitBurst = 3 c.VisitorSubscriberRateLimiting = true s := newTestServer(t, c) @@ -2890,8 +2890,8 @@ func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) { } func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.VisitorRequestLimitBurst = 3 c.VisitorSubscriberRateLimiting = true s := newTestServer(t, c) @@ -2926,8 +2926,8 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) { } func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.AuthDefault = user.PermissionReadWrite c.VisitorSubscriberRateLimiting = true s := newTestServer(t, c) @@ -2948,8 +2948,8 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *t } func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) c.ManagerInterval = 2 * time.Second s := newTestServer(t, c) @@ -2996,9 +2996,9 @@ func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) { } func TestServer_MessageHistoryMaxSize(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) for i := 0; i < 20; i++ { s.messages = int64(i) s.execManager() @@ -3008,9 +3008,9 @@ func TestServer_MessageHistoryMaxSize(t *testing.T) { } func TestServer_MessageCountPersistence(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) s := newTestServer(t, c) s.messages = 1234 s.execManager() @@ -3026,9 +3026,9 @@ func TestServer_MessageCountPersistence(t *testing.T) { } func TestServer_PublishWithUTF8MimeHeader(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{ "X-Filename": "some =?UTF-8?q?=C3=A4?=ttachment.txt", @@ -3054,7 +3054,7 @@ func TestServer_PublishWithUTF8MimeHeader(t *testing.T) { } func TestServer_UpstreamBaseURL_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() var pollID atomic.Pointer[string] upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -3067,7 +3067,7 @@ func TestServer_UpstreamBaseURL_Success(t *testing.T) { })) defer upstreamServer.Close() - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.BaseURL = "http://myserver.internal" c.UpstreamBaseURL = upstreamServer.URL s := newTestServer(t, c) @@ -3086,7 +3086,7 @@ func TestServer_UpstreamBaseURL_Success(t *testing.T) { } func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() var pollID atomic.Pointer[string] upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -3100,7 +3100,7 @@ func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) { })) defer upstreamServer.Close() - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.BaseURL = "http://myserver.internal" c.UpstreamBaseURL = upstreamServer.URL c.UpstreamAccessToken = "tk_1234567890" @@ -3120,14 +3120,14 @@ func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) { } func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("UnifiedPush messages should not be forwarded") })) defer upstreamServer.Close() - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.BaseURL = "http://myserver.internal" c.UpstreamBaseURL = upstreamServer.URL s := newTestServer(t, c) @@ -3146,9 +3146,9 @@ func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) { } func TestServer_MessageTemplate(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ "X-Message": "{{.foo}}", "X-Title": "{{.nested.title}}", @@ -3163,9 +3163,9 @@ func TestServer_MessageTemplate(t *testing.T) { } func TestServer_MessageTemplate_RepeatPlaceholder(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ "Message": "{{.foo}} is {{.foo}}", "Title": "{{.nested.title}} is {{.nested.title}}", @@ -3180,9 +3180,9 @@ func TestServer_MessageTemplate_RepeatPlaceholder(t *testing.T) { } func TestServer_MessageTemplate_JSONBody(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"}}"}` response := request(t, s, "PUT", "/", body, map[string]string{ "m": "{{.foo}}", @@ -3198,9 +3198,9 @@ func TestServer_MessageTemplate_JSONBody(t *testing.T) { } func TestServer_MessageTemplate_MalformedJSONBody(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID"}` response := request(t, s, "PUT", "/", body, map[string]string{ "X-Message": "{{.foo}}", @@ -3214,9 +3214,9 @@ func TestServer_MessageTemplate_MalformedJSONBody(t *testing.T) { } func TestServer_MessageTemplate_PlaceholderTypo(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ "X-Message": "{{.food}}", "X-Title": "{{.neste.title}}", @@ -3231,9 +3231,9 @@ func TestServer_MessageTemplate_PlaceholderTypo(t *testing.T) { } func TestServer_MessageTemplate_MultiplePlaceholders(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ "X-Message": "{{.foo}} is {{.nested.title}}", "X-Template": "1", @@ -3246,9 +3246,9 @@ func TestServer_MessageTemplate_MultiplePlaceholders(t *testing.T) { } func TestServer_MessageTemplate_Range(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) jsonBody := `{"foo": "bar", "errors": [{"level": "severe", "url": "https://severe1.com"},{"level": "warning", "url": "https://warning.com"},{"level": "severe", "url": "https://severe2.com"}]}` response := request(t, s, "PUT", "/mytopic", jsonBody, map[string]string{ "X-Message": `Severe URLs:\n{{range .errors}}{{if eq .level "severe"}}- {{.url}}\n{{end}}{{end}}`, @@ -3262,9 +3262,9 @@ func TestServer_MessageTemplate_Range(t *testing.T) { } func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.MessageSizeLimit = 25 // 25 < len(HTTP body) < 32k, and len(m.Message) < 25 s := newTestServer(t, c) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ @@ -3281,9 +3281,9 @@ func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing. } func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageTooLong(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.MessageSizeLimit = 21 // 21 < len(HTTP body) < 32k, but !len(m.Message) < 21 s := newTestServer(t, c) response := request(t, s, "PUT", "/mytopic", `{"foo":"This is a long message"}`, map[string]string{ @@ -3297,9 +3297,9 @@ func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageTooLong(t *tes } func TestServer_MessageTemplate_Grafana(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"receiver":"ntfy\\.example\\.com/alerts","status":"resolved","alerts":[{"status":"resolved","labels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"annotations":{"summary":"15m load average too high"},"startsAt":"2024-03-15T02:28:00Z","endsAt":"2024-03-15T02:42:00Z","generatorURL":"localhost:3000/alerting/grafana/NW9oDw-4z/view","fingerprint":"becbfb94bd81ef48","silenceURL":"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter","dashboardURL":"","panelURL":"","values":{"B":18.98211314475876,"C":0},"valueString":"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"}],"groupLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts"},"commonLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"commonAnnotations":{"summary":"15m load average too high"},"externalURL":"localhost:3000/","version":"1","groupKey":"{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}","truncatedAlerts":0,"orgId":1,"title":"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)","state":"ok","message":"**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\n"}` response := request(t, s, "PUT", "/mytopic?tpl=yes&title=Grafana+alert:+{{.title}}&message={{.message}}", body, nil) require.Equal(t, 200, response.Code) @@ -3321,9 +3321,9 @@ Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertn } func TestServer_MessageTemplate_GitHub(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"action":"opened","number":1,"pull_request":{"url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1","id":1783420972,"node_id":"PR_kwDOHAbdo85qTNgs","html_url":"https://github.com/binwiederhier/dabble/pull/1","diff_url":"https://github.com/binwiederhier/dabble/pull/1.diff","patch_url":"https://github.com/binwiederhier/dabble/pull/1.patch","issue_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1","number":1,"state":"open","locked":false,"title":"A sample PR from Phil","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"body":null,"created_at":"2024-03-21T02:52:09Z","updated_at":"2024-03-21T02:52:09Z","closed_at":null,"merged_at":null,"merge_commit_sha":null,"assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"draft":false,"commits_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits","review_comments_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments","review_comment_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b","head":{"label":"binwiederhier:aa","ref":"aa","sha":"5703842cc5715ed1e358d23ebb693db09747ae9b","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"base":{"label":"binwiederhier:main","ref":"main","sha":"72d931a20bb83d123ab45accaf761150c8b01211","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"_links":{"self":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1"},"html":{"href":"https://github.com/binwiederhier/dabble/pull/1"},"issue":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1"},"comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments"},"review_comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments"},"review_comment":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits"},"statuses":{"href":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b"}},"author_association":"OWNER","auto_merge":null,"active_lock_reason":null,"merged":false,"mergeable":null,"rebaseable":null,"mergeable_state":"unknown","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":1,"additions":1,"deletions":1,"changed_files":1},"repository":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main"},"sender":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false}}` response := request(t, s, "PUT", `/mytopic?tpl=yes&message=[{{.pull_request.head.repo.full_name}}]+Pull+request+{{if+eq+.action+"opened"}}OPENED{{else}}CLOSED{{end}}:+{{.pull_request.title}}`, body, nil) require.Equal(t, 200, response.Code) @@ -3334,9 +3334,9 @@ func TestServer_MessageTemplate_GitHub(t *testing.T) { } func TestServer_MessageTemplate_GitHub2(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) body := `{"action":"opened","number":1,"pull_request":{"url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1","id":1783420972,"node_id":"PR_kwDOHAbdo85qTNgs","html_url":"https://github.com/binwiederhier/dabble/pull/1","diff_url":"https://github.com/binwiederhier/dabble/pull/1.diff","patch_url":"https://github.com/binwiederhier/dabble/pull/1.patch","issue_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1","number":1,"state":"open","locked":false,"title":"A sample PR from Phil","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"body":null,"created_at":"2024-03-21T02:52:09Z","updated_at":"2024-03-21T02:52:09Z","closed_at":null,"merged_at":null,"merge_commit_sha":null,"assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"draft":false,"commits_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits","review_comments_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments","review_comment_url":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b","head":{"label":"binwiederhier:aa","ref":"aa","sha":"5703842cc5715ed1e358d23ebb693db09747ae9b","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"base":{"label":"binwiederhier:main","ref":"main","sha":"72d931a20bb83d123ab45accaf761150c8b01211","user":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"repo":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"use_squash_pr_title_as_default":false,"squash_merge_commit_message":"COMMIT_MESSAGES","squash_merge_commit_title":"COMMIT_OR_PR_TITLE","merge_commit_message":"PR_TITLE","merge_commit_title":"MERGE_MESSAGE"}},"_links":{"self":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1"},"html":{"href":"https://github.com/binwiederhier/dabble/pull/1"},"issue":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1"},"comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/issues/1/comments"},"review_comments":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/comments"},"review_comment":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/binwiederhier/dabble/pulls/1/commits"},"statuses":{"href":"https://api.github.com/repos/binwiederhier/dabble/statuses/5703842cc5715ed1e358d23ebb693db09747ae9b"}},"author_association":"OWNER","auto_merge":null,"active_lock_reason":null,"merged":false,"mergeable":null,"rebaseable":null,"mergeable_state":"unknown","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":1,"additions":1,"deletions":1,"changed_files":1},"repository":{"id":470212003,"node_id":"R_kgDOHAbdow","name":"dabble","full_name":"binwiederhier/dabble","private":false,"owner":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false},"html_url":"https://github.com/binwiederhier/dabble","description":"A repo for dabbling","fork":false,"url":"https://api.github.com/repos/binwiederhier/dabble","forks_url":"https://api.github.com/repos/binwiederhier/dabble/forks","keys_url":"https://api.github.com/repos/binwiederhier/dabble/keys{/key_id}","collaborators_url":"https://api.github.com/repos/binwiederhier/dabble/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/binwiederhier/dabble/teams","hooks_url":"https://api.github.com/repos/binwiederhier/dabble/hooks","issue_events_url":"https://api.github.com/repos/binwiederhier/dabble/issues/events{/number}","events_url":"https://api.github.com/repos/binwiederhier/dabble/events","assignees_url":"https://api.github.com/repos/binwiederhier/dabble/assignees{/user}","branches_url":"https://api.github.com/repos/binwiederhier/dabble/branches{/branch}","tags_url":"https://api.github.com/repos/binwiederhier/dabble/tags","blobs_url":"https://api.github.com/repos/binwiederhier/dabble/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/binwiederhier/dabble/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/binwiederhier/dabble/git/refs{/sha}","trees_url":"https://api.github.com/repos/binwiederhier/dabble/git/trees{/sha}","statuses_url":"https://api.github.com/repos/binwiederhier/dabble/statuses/{sha}","languages_url":"https://api.github.com/repos/binwiederhier/dabble/languages","stargazers_url":"https://api.github.com/repos/binwiederhier/dabble/stargazers","contributors_url":"https://api.github.com/repos/binwiederhier/dabble/contributors","subscribers_url":"https://api.github.com/repos/binwiederhier/dabble/subscribers","subscription_url":"https://api.github.com/repos/binwiederhier/dabble/subscription","commits_url":"https://api.github.com/repos/binwiederhier/dabble/commits{/sha}","git_commits_url":"https://api.github.com/repos/binwiederhier/dabble/git/commits{/sha}","comments_url":"https://api.github.com/repos/binwiederhier/dabble/comments{/number}","issue_comment_url":"https://api.github.com/repos/binwiederhier/dabble/issues/comments{/number}","contents_url":"https://api.github.com/repos/binwiederhier/dabble/contents/{+path}","compare_url":"https://api.github.com/repos/binwiederhier/dabble/compare/{base}...{head}","merges_url":"https://api.github.com/repos/binwiederhier/dabble/merges","archive_url":"https://api.github.com/repos/binwiederhier/dabble/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/binwiederhier/dabble/downloads","issues_url":"https://api.github.com/repos/binwiederhier/dabble/issues{/number}","pulls_url":"https://api.github.com/repos/binwiederhier/dabble/pulls{/number}","milestones_url":"https://api.github.com/repos/binwiederhier/dabble/milestones{/number}","notifications_url":"https://api.github.com/repos/binwiederhier/dabble/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/binwiederhier/dabble/labels{/name}","releases_url":"https://api.github.com/repos/binwiederhier/dabble/releases{/id}","deployments_url":"https://api.github.com/repos/binwiederhier/dabble/deployments","created_at":"2022-03-15T15:06:17Z","updated_at":"2022-03-15T15:06:17Z","pushed_at":"2024-03-21T02:52:10Z","git_url":"git://github.com/binwiederhier/dabble.git","ssh_url":"git@github.com:binwiederhier/dabble.git","clone_url":"https://github.com/binwiederhier/dabble.git","svn_url":"https://github.com/binwiederhier/dabble","homepage":null,"size":1,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":0,"open_issues":1,"watchers":0,"default_branch":"main"},"sender":{"login":"binwiederhier","id":664597,"node_id":"MDQ6VXNlcjY2NDU5Nw==","avatar_url":"https://avatars.githubusercontent.com/u/664597?v=4","gravatar_id":"","url":"https://api.github.com/users/binwiederhier","html_url":"https://github.com/binwiederhier","followers_url":"https://api.github.com/users/binwiederhier/followers","following_url":"https://api.github.com/users/binwiederhier/following{/other_user}","gists_url":"https://api.github.com/users/binwiederhier/gists{/gist_id}","starred_url":"https://api.github.com/users/binwiederhier/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/binwiederhier/subscriptions","organizations_url":"https://api.github.com/users/binwiederhier/orgs","repos_url":"https://api.github.com/users/binwiederhier/repos","events_url":"https://api.github.com/users/binwiederhier/events{/privacy}","received_events_url":"https://api.github.com/users/binwiederhier/received_events","type":"User","site_admin":false}}` response := request(t, s, "PUT", `/mytopic?tpl=yes&title={{if+eq+.action+"opened"}}New+PR:+%23{{.number}}+by+{{.pull_request.user.login}}{{else}}[{{.action}}]+PR:+%23{{.number}}+by+{{.pull_request.user.login}}{{end}}&message={{.pull_request.title}}+in+{{.repository.full_name}}.+View+more+at+{{.pull_request.html_url}}`, body, nil) require.Equal(t, 200, response.Code) @@ -3347,9 +3347,9 @@ func TestServer_MessageTemplate_GitHub2(t *testing.T) { } func TestServer_MessageTemplate_DisallowedCalls(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) disallowedTemplates := []string{ `{{template ""}}`, `{{- template ""}}`, @@ -3377,9 +3377,9 @@ template ""}}`, } func TestServer_MessageTemplate_SprigFunctions(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) bodies := []string{ `{"foo":"bar","nested":{"title":"here"}}`, `{"topic":"ntfy-test"}`, @@ -3412,9 +3412,9 @@ func TestServer_MessageTemplate_SprigFunctions(t *testing.T) { } func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ "X-Message": `{{ env "PATH" }}`, "X-Template": "1", @@ -3426,9 +3426,9 @@ func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { } func TestServer_MessageTemplate_InlineNewlines(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{ "X-Message": `{{"New\nlines"}}`, "X-Title": `{{"New\nlines"}}`, @@ -3445,9 +3445,9 @@ lines`, m.Title) } func TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{ "X-Message": `{{.foo}}{{"\n"}}{{.food}}`, "X-Title": `{{.food}}{{"\n"}}{{.foo}}`, @@ -3464,9 +3464,9 @@ bar`, m.Title) } func TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.TemplateDir = t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(` title: | @@ -3495,9 +3495,9 @@ var ( ) func TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) @@ -3516,9 +3516,9 @@ These are the things you need to do to get iOS push notifications to work: } func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) @@ -3546,9 +3546,9 @@ Looks like this has already been fixed by #498, regression?`, m.Message) } func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.TemplateDir = t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(` title: | @@ -3567,9 +3567,9 @@ message: | } func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ "X-Message": `{{ repeat 9999 "mystring" }}`, "X-Template": "1", @@ -3581,9 +3581,9 @@ func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) { } func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ "X-Message": `{{ repeat 10001 "mystring" }}`, "X-Template": "1", @@ -3595,9 +3595,9 @@ func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) { } func TestServer_MessageTemplate_Until100_000(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ "X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`, "X-Template": "1", @@ -3609,9 +3609,9 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) { } func TestServer_MessageTemplate_Priority(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{"priority":"5"}`, map[string]string{ "X-Message": "Test message", "X-Priority": "{{.priority}}", @@ -3626,9 +3626,9 @@ func TestServer_MessageTemplate_Priority(t *testing.T) { } func TestServer_MessageTemplate_Priority_Conditional(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Test with error status -> priority 5 response := request(t, s, "PUT", "/mytopic", `{"status":"Error","message":"Something went wrong"}`, map[string]string{ @@ -3655,9 +3655,9 @@ func TestServer_MessageTemplate_Priority_Conditional(t *testing.T) { } func TestServer_MessageTemplate_Priority_NamedValue(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{"severity":"high"}`, map[string]string{ "X-Message": "Alert", "X-Priority": "{{.severity}}", @@ -3671,9 +3671,9 @@ func TestServer_MessageTemplate_Priority_NamedValue(t *testing.T) { } func TestServer_MessageTemplate_Priority_Invalid(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic", `{"priority":"invalid"}`, map[string]string{ "X-Message": "Test message", "X-Priority": "{{.priority}}", @@ -3686,9 +3686,9 @@ func TestServer_MessageTemplate_Priority_Invalid(t *testing.T) { } func TestServer_MessageTemplate_Priority_QueryParam(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "PUT", "/mytopic?template=1&priority={{.priority}}", `{"priority":"max"}`, nil) require.Equal(t, 200, response.Code) @@ -3698,9 +3698,9 @@ func TestServer_MessageTemplate_Priority_QueryParam(t *testing.T) { } func TestServer_MessageTemplate_Priority_FromTemplateFile(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - c := newTestConfig(t) + c := newTestConfig(t, databaseURL) c.TemplateDir = t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "priority-test.yml"), []byte(` title: "{{.title}}" @@ -3732,9 +3732,9 @@ priority: '{{if eq .level "critical"}}5{{else if eq .level "warning"}}4{{else}}3 } func TestServer_DeleteMessage(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish a message with a sequence ID response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil) @@ -3766,9 +3766,9 @@ func TestServer_DeleteMessage(t *testing.T) { } func TestServer_ClearMessage(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish a message with a sequence ID response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil) @@ -3800,10 +3800,10 @@ func TestServer_ClearMessage(t *testing.T) { } func TestServer_ClearMessage_ReadEndpoint(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // Test that /topic/seq/read also works t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish a message response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil) @@ -3819,9 +3819,9 @@ func TestServer_ClearMessage_ReadEndpoint(t *testing.T) { } func TestServer_UpdateMessage(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish original message response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil) @@ -3854,9 +3854,9 @@ func TestServer_UpdateMessage(t *testing.T) { } func TestServer_UpdateMessage_UsingMessageID(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish original message without a sequence ID response := request(t, s, "PUT", "/mytopic", "original message", nil) @@ -3890,9 +3890,9 @@ func TestServer_UpdateMessage_UsingMessageID(t *testing.T) { } func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Test invalid sequence ID for delete (returns 404 because route doesn't match) response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil) @@ -3905,9 +3905,9 @@ func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) { } func TestServer_DeleteMessage_WithFirebase(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) // Publish a message @@ -3930,9 +3930,9 @@ func TestServer_DeleteMessage_WithFirebase(t *testing.T) { } func TestServer_ClearMessage_WithFirebase(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { sender := newTestFirebaseSender(10) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true}) // Publish a message @@ -3954,9 +3954,9 @@ func TestServer_ClearMessage_WithFirebase(t *testing.T) { } func TestServer_UpdateScheduledMessage(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish a scheduled message (future delivery) response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil) @@ -3991,9 +3991,9 @@ func TestServer_UpdateScheduledMessage(t *testing.T) { } func TestServer_DeleteScheduledMessage(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish a scheduled message (future delivery) response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil) @@ -4026,9 +4026,9 @@ func TestServer_DeleteScheduledMessage(t *testing.T) { } func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish scheduled messages with same sequence ID in different topics response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil) @@ -4058,9 +4058,9 @@ func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) { } func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish a scheduled message with an attachment content := util.RandomString(5000) // > 4096 to trigger attachment @@ -4092,9 +4092,9 @@ func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) { } func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Publish a scheduled message with an attachment content := util.RandomString(5000) // > 4096 to trigger attachment @@ -4125,11 +4125,9 @@ func newMemTestCache(t *testing.T) message.Store { return c } -var testBackendDatabaseURLs sync.Map - -func forEachBackend(t *testing.T, f func(t *testing.T)) { +func forEachBackend(t *testing.T, f func(t *testing.T, databaseURL string)) { t.Run("sqlite", func(t *testing.T) { - f(t) + f(t, "") }) t.Run("postgres", func(t *testing.T) { dsn := os.Getenv("NTFY_TEST_DATABASE_URL") @@ -4148,29 +4146,20 @@ func forEachBackend(t *testing.T, f func(t *testing.T)) { q.Set("search_path", schema) u.RawQuery = q.Encode() schemaDSN := u.String() - testBackendDatabaseURLs.Store(t, schemaDSN) t.Cleanup(func() { - testBackendDatabaseURLs.Delete(t) cleanDB, _ := sql.Open("pgx", dsn) cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) cleanDB.Close() }) - f(t) + f(t, schemaDSN) }) } -func testBackendDatabaseURL(t *testing.T) string { - if dbURL, ok := testBackendDatabaseURLs.Load(t); ok { - return dbURL.(string) - } - return "" -} - -func newTestConfig(t *testing.T) *Config { +func newTestConfig(t *testing.T, databaseURL string) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" - if dbURL := testBackendDatabaseURL(t); dbURL != "" { - conf.DatabaseURL = dbURL + if databaseURL != "" { + conf.DatabaseURL = databaseURL } else { conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" @@ -4189,8 +4178,8 @@ func configureAuth(t *testing.T, conf *Config) *Config { return conf } -func newTestConfigWithAuthFile(t *testing.T) *Config { - conf := newTestConfig(t) +func newTestConfigWithAuthFile(t *testing.T, databaseURL string) *Config { + conf := newTestConfig(t, databaseURL) conf = configureAuth(t, conf) return conf } @@ -4360,7 +4349,7 @@ func (w *closableResponseWriter) Close() { } func TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // This test reproduces the panic from https://github.com/binwiederhier/ntfy/issues/338: // // panic: runtime error: invalid memory address or nil pointer dereference @@ -4385,7 +4374,7 @@ func TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) { // the closed response writer (which in production causes a nil pointer panic on Flush). // With the fix, the subscriber sees closed=true and returns without writing. t.Parallel() - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) rw := newClosableResponseWriter() ctx, cancel := context.WithCancel(context.Background()) @@ -4441,10 +4430,10 @@ func TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) { } func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { // Test that handleError does not call WriteHeader for WebSocket errors wrapped // with errWebSocketPostUpgrade (indicating the connection was hijacked) - s := newTestServer(t, newTestConfig(t)) + s := newTestServer(t, newTestConfig(t, databaseURL)) // Create a WebSocket upgrade request r, _ := http.NewRequest("GET", "/mytopic/ws", nil) diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 29768b53..e3582609 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -14,7 +14,7 @@ import ( ) func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { var called, verified atomic.Bool var code atomic.Pointer[string] twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -51,7 +51,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { })) defer twilioCallsServer.Close() - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.TwilioVerifyBaseURL = twilioVerifyServer.URL c.TwilioCallsBaseURL = twilioCallsServer.URL c.TwilioAccount = "AC1234567890" @@ -117,7 +117,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { } func TestServer_Twilio_Call_Success(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { var called atomic.Bool twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if called.Load() { @@ -132,7 +132,7 @@ func TestServer_Twilio_Call_Success(t *testing.T) { })) defer twilioServer.Close() - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.TwilioCallsBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" @@ -164,7 +164,7 @@ func TestServer_Twilio_Call_Success(t *testing.T) { } func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { var called atomic.Bool twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if called.Load() { @@ -179,7 +179,7 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { })) defer twilioServer.Close() - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.TwilioCallsBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" @@ -211,7 +211,7 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { } func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { - forEachBackend(t, func(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { var called atomic.Bool twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if called.Load() { @@ -226,7 +226,7 @@ func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { })) defer twilioServer.Close() - c := newTestConfigWithAuthFile(t) + c := newTestConfigWithAuthFile(t, databaseURL) c.TwilioCallsBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" @@ -274,8 +274,8 @@ func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { } func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.TwilioCallsBaseURL = "http://dummy.invalid" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" @@ -301,8 +301,8 @@ func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { } func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" @@ -317,8 +317,8 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { } func TestServer_Twilio_Call_Anonymous(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - c := newTestConfigWithAuthFile(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" @@ -333,8 +333,8 @@ func TestServer_Twilio_Call_Anonymous(t *testing.T) { } func TestServer_Twilio_Call_Unconfigured(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "x-call": "+1234", }) diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go index a79f0369..047c8708 100644 --- a/server/server_webpush_test.go +++ b/server/server_webpush_test.go @@ -26,21 +26,21 @@ const ( ) func TestServer_WebPush_Enabled(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - conf := newTestConfig(t) + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfig(t, databaseURL) conf.WebRoot = "" // Disable web app s := newTestServer(t, conf) rr := request(t, s, "GET", "/manifest.webmanifest", "", nil) require.Equal(t, 404, rr.Code) - conf2 := newTestConfig(t) + conf2 := newTestConfig(t, databaseURL) s2 := newTestServer(t, conf2) rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil) require.Equal(t, 404, rr.Code) - conf3 := newTestConfigWithWebPush(t) + conf3 := newTestConfigWithWebPush(t, databaseURL) s3 := newTestServer(t, conf3) rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil) @@ -50,8 +50,8 @@ func TestServer_WebPush_Enabled(t *testing.T) { }) } func TestServer_WebPush_Disabled(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) require.Equal(t, 404, response.Code) @@ -59,8 +59,8 @@ func TestServer_WebPush_Disabled(t *testing.T) { } func TestServer_WebPush_TopicAdd(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil) require.Equal(t, 200, response.Code) @@ -78,8 +78,8 @@ func TestServer_WebPush_TopicAdd(t *testing.T) { } func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil) require.Equal(t, 400, response.Code) @@ -88,8 +88,8 @@ func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) { } func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) topicList := make([]string, 51) for i := range topicList { @@ -103,8 +103,8 @@ func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) { } func TestServer_WebPush_TopicUnsubscribe(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) addSubscription(t, s, testWebPushEndpoint, "test-topic") requireSubscriptionCount(t, s, "test-topic", 1) @@ -118,8 +118,8 @@ func TestServer_WebPush_TopicUnsubscribe(t *testing.T) { } func TestServer_WebPush_Delete(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) addSubscription(t, s, testWebPushEndpoint, "test-topic") requireSubscriptionCount(t, s, "test-topic", 1) @@ -133,8 +133,8 @@ func TestServer_WebPush_Delete(t *testing.T) { } func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - config := configureAuth(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL)) config.AuthDefault = user.PermissionDenyAll s := newTestServer(t, config) @@ -155,8 +155,8 @@ func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) { } func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - config := configureAuth(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL)) config.AuthDefault = user.PermissionDenyAll s := newTestServer(t, config) @@ -168,8 +168,8 @@ func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) { } func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - config := configureAuth(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL)) s := newTestServer(t, config) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) @@ -193,8 +193,8 @@ func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) { } func TestServer_WebPush_Publish(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) var received atomic.Bool pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -217,8 +217,8 @@ func TestServer_WebPush_Publish(t *testing.T) { } func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) var received atomic.Bool pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -247,8 +247,8 @@ func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) { } func TestServer_WebPush_Expiry(t *testing.T) { - forEachBackend(t, func(t *testing.T) { - s := newTestServer(t, newTestConfigWithWebPush(t)) + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) var received atomic.Bool @@ -307,8 +307,8 @@ func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLen require.Len(t, subs, expectedLength) } -func newTestConfigWithWebPush(t *testing.T) *Config { - conf := newTestConfig(t) +func newTestConfigWithWebPush(t *testing.T, databaseURL string) *Config { + conf := newTestConfig(t, databaseURL) privateKey, publicKey, err := webpush.GenerateVAPIDKeys() require.Nil(t, err) if conf.DatabaseURL == "" { diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go index d4178a40..3294fe51 100644 --- a/server/smtp_server_test.go +++ b/server/smtp_server_test.go @@ -1411,7 +1411,7 @@ what's up type smtpHandlerFunc func(http.ResponseWriter, *http.Request) func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) { - conf = newTestConfig(t) + conf = newTestConfig(t, "") conf.SMTPServerListen = ":25" conf.SMTPServerDomain = "ntfy.sh" conf.SMTPServerAddrPrefix = "ntfy-" diff --git a/user/store_postgres.go b/user/store_postgres.go index a2a78e3f..fb3b221a 100644 --- a/user/store_postgres.go +++ b/user/store_postgres.go @@ -212,6 +212,7 @@ func NewPostgresStore(dsn string) (Store, error) { if err != nil { return nil, err } + db.SetMaxOpenConns(25) if err := db.Ping(); err != nil { return nil, err } diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index 661e18cd..1403af68 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -76,6 +76,7 @@ func NewPostgresStore(dsn string) (Store, error) { if err != nil { return nil, err } + db.SetMaxOpenConns(25) if err := db.Ping(); err != nil { return nil, err } From 4cbd80c68e7caac808d4ab97000c4782797360e8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 19 Feb 2026 22:34:53 -0500 Subject: [PATCH 203/548] Use one PG connection, add support for connection params --- cmd/user.go | 11 +++-- message/store.go | 6 --- message/store_postgres.go | 14 +----- message/store_postgres_test.go | 21 +++++---- postgres/postgres.go | 86 ++++++++++++++++++++++++++++++++++ server/server.go | 31 ++++++++---- server/server_test.go | 13 ++--- user/manager_test.go | 15 ++++-- user/store_postgres.go | 14 +----- user/store_postgres_test.go | 21 +++++---- webpush/store_postgres.go | 14 +----- webpush/store_postgres_test.go | 21 +++++---- 12 files changed, 172 insertions(+), 95 deletions(-) create mode 100644 postgres/postgres.go diff --git a/cmd/user.go b/cmd/user.go index bf822970..e3d91cbe 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -6,13 +6,14 @@ import ( "crypto/subtle" "errors" "fmt" - "heckel.io/ntfy/v2/server" - "heckel.io/ntfy/v2/user" "os" "strings" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/postgres" + "heckel.io/ntfy/v2/server" + "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -379,7 +380,11 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { } var store user.Store if databaseURL != "" { - store, err = user.NewPostgresStore(databaseURL) + db, dbErr := postgres.OpenDB(databaseURL) + if dbErr != nil { + return nil, dbErr + } + store, err = user.NewPostgresStore(db) } else if authFile != "" { if !util.FileExists(authFile) { return nil, errors.New("auth-file does not exist; please start the server at least once to create it") diff --git a/message/store.go b/message/store.go index 05ab31d0..eeb34739 100644 --- a/message/store.go +++ b/message/store.go @@ -25,7 +25,6 @@ var errNoRows = errors.New("no rows found") type Store interface { AddMessage(m *model.Message) error AddMessages(ms []*model.Message) error - DB() *sql.DB Message(id string) (*model.Message, error) MessageCounts() (map[string]int, error) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) @@ -99,11 +98,6 @@ func newCommonStore(db *sql.DB, queries storeQueries, batchSize int, batchTimeou return c } -// DB returns the underlying database connection -func (c *commonStore) DB() *sql.DB { - return c.db -} - // AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asynchronously. func (c *commonStore) AddMessage(m *model.Message) error { if c.queue != nil { diff --git a/message/store_postgres.go b/message/store_postgres.go index 7ddb2464..32150e2d 100644 --- a/message/store_postgres.go +++ b/message/store_postgres.go @@ -3,8 +3,6 @@ package message import ( "database/sql" "time" - - _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) // PostgreSQL runtime query constants @@ -104,16 +102,8 @@ var pgQueries = storeQueries{ updateMessageTime: pgUpdateMessageTimesQuery, } -// NewPostgresStore creates a new PostgreSQL-backed message cache store. -func NewPostgresStore(dsn string, batchSize int, batchTimeout time.Duration) (Store, error) { - db, err := sql.Open("pgx", dsn) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(25) - if err := db.Ping(); err != nil { - return nil, err - } +// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. +func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (Store, error) { if err := setupPostgresDB(db); err != nil { return nil, err } diff --git a/message/store_postgres_test.go b/message/store_postgres_test.go index 930d700d..ebe60c70 100644 --- a/message/store_postgres_test.go +++ b/message/store_postgres_test.go @@ -1,7 +1,6 @@ package message_test import ( - "database/sql" "fmt" "net/url" "os" @@ -9,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/message" + "heckel.io/ntfy/v2/postgres" "heckel.io/ntfy/v2/util" ) @@ -17,24 +17,25 @@ func newTestPostgresStore(t *testing.T) message.Store { if dsn == "" { t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") } - // Create a unique schema for this test schema := fmt.Sprintf("test_%s", util.RandomString(10)) - setupDB, err := sql.Open("pgx", dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - // Open store with search_path set to the new schema u, err := url.Parse(dsn) require.Nil(t, err) q := u.Query() q.Set("search_path", schema) u.RawQuery = q.Encode() - store, err := message.NewPostgresStore(u.String(), 0, 0) + schemaDSN := u.String() + setupDB, err := postgres.OpenDB(dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + db, err := postgres.OpenDB(schemaDSN) + require.Nil(t, err) + store, err := message.NewPostgresStore(db, 0, 0) require.Nil(t, err) t.Cleanup(func() { store.Close() - cleanDB, err := sql.Open("pgx", dsn) + cleanDB, err := postgres.OpenDB(dsn) if err == nil { cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) cleanDB.Close() diff --git a/postgres/postgres.go b/postgres/postgres.go new file mode 100644 index 00000000..94913f58 --- /dev/null +++ b/postgres/postgres.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "database/sql" + "fmt" + "net/url" + "strconv" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver +) + +const defaultMaxOpenConns = 25 + +// OpenDB opens a PostgreSQL database connection pool from a DSN string. It supports custom +// query parameters for pool configuration: pool_max_conns (default 25), pool_max_idle_conns, +// pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from +// the DSN before passing it to the driver. +func OpenDB(dsn string) (*sql.DB, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("invalid database URL: %w", err) + } + q := u.Query() + maxOpenConns, err := extractIntParam(q, "pool_max_conns", defaultMaxOpenConns) + if err != nil { + return nil, err + } + maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0) + if err != nil { + return nil, err + } + connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0) + if err != nil { + return nil, err + } + connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0) + if err != nil { + return nil, err + } + u.RawQuery = q.Encode() + db, err := sql.Open("pgx", u.String()) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(maxOpenConns) + if maxIdleConns > 0 { + db.SetMaxIdleConns(maxIdleConns) + } + if connMaxLifetime > 0 { + db.SetConnMaxLifetime(connMaxLifetime) + } + if connMaxIdleTime > 0 { + db.SetConnMaxIdleTime(connMaxIdleTime) + } + if err := db.Ping(); err != nil { + return nil, err + } + return db, nil +} + +func extractIntParam(q url.Values, key string, defaultValue int) (int, error) { + s := q.Get(key) + if s == "" { + return defaultValue, nil + } + q.Del(key) + v, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) + } + return v, nil +} + +func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) { + s := q.Get(key) + if s == "" { + return defaultValue, nil + } + q.Del(key) + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) + } + return d, nil +} diff --git a/server/server.go b/server/server.go index 80f367c2..243a5094 100644 --- a/server/server.go +++ b/server/server.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "database/sql" "embed" "encoding/base64" "encoding/json" @@ -36,6 +37,7 @@ import ( "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/payments" + "heckel.io/ntfy/v2/postgres" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util/sprig" @@ -45,6 +47,7 @@ import ( // Server is the main server, providing the UI and API for ntfy type Server struct { config *Config + db *sql.DB // Shared PostgreSQL connection pool, nil when using SQLite httpServer *http.Server httpsServer *http.Server httpMetricsServer *http.Server @@ -175,14 +178,23 @@ func New(conf *Config) (*Server, error) { if payments.Available && conf.StripeSecretKey != "" { stripe = newStripeAPI() } - messageCache, err := createMessageCache(conf) + // Open shared PostgreSQL connection pool if configured + var db *sql.DB + if conf.DatabaseURL != "" { + var err error + db, err = postgres.OpenDB(conf.DatabaseURL) + if err != nil { + return nil, err + } + } + messageCache, err := createMessageCache(conf, db) if err != nil { return nil, err } var wp webpush.Store if conf.WebPushPublicKey != "" { - if conf.DatabaseURL != "" { - wp, err = webpush.NewPostgresStore(conf.DatabaseURL) + if db != nil { + wp, err = webpush.NewPostgresStore(db) } else { wp, err = webpush.NewSQLiteStore(conf.WebPushFile, conf.WebPushStartupQueries) } @@ -210,7 +222,7 @@ func New(conf *Config) (*Server, error) { } } var userManager *user.Manager - if conf.AuthFile != "" || conf.DatabaseURL != "" { + if conf.AuthFile != "" || db != nil { authConfig := &user.Config{ Filename: conf.AuthFile, DatabaseURL: conf.DatabaseURL, @@ -224,8 +236,8 @@ func New(conf *Config) (*Server, error) { QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } var store user.Store - if conf.DatabaseURL != "" { - store, err = user.NewPostgresStore(conf.DatabaseURL) + if db != nil { + store, err = user.NewPostgresStore(db) } else { store, err = user.NewSQLiteStore(conf.AuthFile, conf.AuthStartupQueries) } @@ -253,6 +265,7 @@ func New(conf *Config) (*Server, error) { } s := &Server{ config: conf, + db: db, messageCache: messageCache, webPush: wp, fileCache: fileCache, @@ -269,11 +282,11 @@ func New(conf *Config) (*Server, error) { return s, nil } -func createMessageCache(conf *Config) (message.Store, error) { +func createMessageCache(conf *Config, db *sql.DB) (message.Store, error) { if conf.CacheDuration == 0 { return message.NewNopStore() - } else if conf.DatabaseURL != "" { - return message.NewPostgresStore(conf.DatabaseURL, conf.CacheBatchSize, conf.CacheBatchTimeout) + } else if db != nil { + return message.NewPostgresStore(db, conf.CacheBatchSize, conf.CacheBatchTimeout) } else if conf.CacheFile != "" { return message.NewSQLiteStore(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false) } diff --git a/server/server_test.go b/server/server_test.go index 9d27f361..88bc1d5a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4,7 +4,6 @@ import ( "bufio" "context" "crypto/rand" - "database/sql" _ "embed" "encoding/base64" "encoding/json" @@ -24,12 +23,12 @@ import ( "testing" "time" - _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/postgres" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -4135,7 +4134,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, databaseURL string)) { t.Skip("NTFY_TEST_DATABASE_URL not set") } schema := fmt.Sprintf("test_%s", util.RandomString(10)) - setupDB, err := sql.Open("pgx", dsn) + setupDB, err := postgres.OpenDB(dsn) require.Nil(t, err) _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) require.Nil(t, err) @@ -4147,9 +4146,11 @@ func forEachBackend(t *testing.T, f func(t *testing.T, databaseURL string)) { u.RawQuery = q.Encode() schemaDSN := u.String() t.Cleanup(func() { - cleanDB, _ := sql.Open("pgx", dsn) - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() + cleanDB, _ := postgres.OpenDB(dsn) + if cleanDB != nil { + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + } }) f(t, schemaDSN) }) diff --git a/user/manager_test.go b/user/manager_test.go index 13cff861..133c7b05 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" + "heckel.io/ntfy/v2/postgres" "heckel.io/ntfy/v2/util" ) @@ -38,7 +39,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newStore newStoreFunc)) { t.Skip("NTFY_TEST_DATABASE_URL not set") } schema := fmt.Sprintf("test_%s", util.RandomString(10)) - setupDB, err := sql.Open("pgx", dsn) + setupDB, err := postgres.OpenDB(dsn) require.Nil(t, err) _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) require.Nil(t, err) @@ -50,12 +51,16 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newStore newStoreFunc)) { u.RawQuery = q.Encode() schemaDSN := u.String() t.Cleanup(func() { - cleanDB, _ := sql.Open("pgx", dsn) - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() + cleanDB, _ := postgres.OpenDB(dsn) + if cleanDB != nil { + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + } }) f(t, func() Store { - store, err := NewPostgresStore(schemaDSN) + db, err := postgres.OpenDB(schemaDSN) + require.Nil(t, err) + store, err := NewPostgresStore(db) require.Nil(t, err) return store }) diff --git a/user/store_postgres.go b/user/store_postgres.go index fb3b221a..be7f998f 100644 --- a/user/store_postgres.go +++ b/user/store_postgres.go @@ -2,8 +2,6 @@ package user import ( "database/sql" - - _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) // PostgreSQL queries @@ -206,16 +204,8 @@ const ( ` ) -// NewPostgresStore creates a new PostgreSQL-backed user store -func NewPostgresStore(dsn string) (Store, error) { - db, err := sql.Open("pgx", dsn) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(25) - if err := db.Ping(); err != nil { - return nil, err - } +// NewPostgresStore creates a new PostgreSQL-backed user store using an existing database connection pool. +func NewPostgresStore(db *sql.DB) (Store, error) { if err := setupPostgres(db); err != nil { return nil, err } diff --git a/user/store_postgres_test.go b/user/store_postgres_test.go index bc3fab68..ea222539 100644 --- a/user/store_postgres_test.go +++ b/user/store_postgres_test.go @@ -1,13 +1,13 @@ package user_test import ( - "database/sql" "fmt" "net/url" "os" "testing" "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/postgres" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -17,24 +17,25 @@ func newTestPostgresStore(t *testing.T) user.Store { if dsn == "" { t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") } - // Create a unique schema for this test schema := fmt.Sprintf("test_%s", util.RandomString(10)) - setupDB, err := sql.Open("pgx", dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - // Open store with search_path set to the new schema u, err := url.Parse(dsn) require.Nil(t, err) q := u.Query() q.Set("search_path", schema) u.RawQuery = q.Encode() - store, err := user.NewPostgresStore(u.String()) + schemaDSN := u.String() + setupDB, err := postgres.OpenDB(dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + db, err := postgres.OpenDB(schemaDSN) + require.Nil(t, err) + store, err := user.NewPostgresStore(db) require.Nil(t, err) t.Cleanup(func() { store.Close() - cleanDB, err := sql.Open("pgx", dsn) + cleanDB, err := postgres.OpenDB(dsn) if err == nil { cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) cleanDB.Close() diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index 1403af68..c6366367 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -3,8 +3,6 @@ package webpush import ( "database/sql" "fmt" - - _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) const ( @@ -70,16 +68,8 @@ const ( pgSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'webpush'` ) -// NewPostgresStore creates a new PostgreSQL-backed web push store. -func NewPostgresStore(dsn string) (Store, error) { - db, err := sql.Open("pgx", dsn) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(25) - if err := db.Ping(); err != nil { - return nil, err - } +// NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool. +func NewPostgresStore(db *sql.DB) (Store, error) { if err := setupPostgresDB(db); err != nil { return nil, err } diff --git a/webpush/store_postgres_test.go b/webpush/store_postgres_test.go index 0124441c..bacd8163 100644 --- a/webpush/store_postgres_test.go +++ b/webpush/store_postgres_test.go @@ -1,13 +1,13 @@ package webpush_test import ( - "database/sql" "fmt" "net/url" "os" "testing" "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/postgres" "heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/webpush" ) @@ -17,24 +17,25 @@ func newTestPostgresStore(t *testing.T) webpush.Store { if dsn == "" { t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") } - // Create a unique schema for this test schema := fmt.Sprintf("test_%s", util.RandomString(10)) - setupDB, err := sql.Open("pgx", dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - // Open store with search_path set to the new schema u, err := url.Parse(dsn) require.Nil(t, err) q := u.Query() q.Set("search_path", schema) u.RawQuery = q.Encode() - store, err := webpush.NewPostgresStore(u.String()) + schemaDSN := u.String() + setupDB, err := postgres.OpenDB(dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + db, err := postgres.OpenDB(schemaDSN) + require.Nil(t, err) + store, err := webpush.NewPostgresStore(db) require.Nil(t, err) t.Cleanup(func() { store.Close() - cleanDB, err := sql.Open("pgx", dsn) + cleanDB, err := postgres.OpenDB(dsn) if err == nil { cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) cleanDB.Close() From bf265449aceaeabeef9246e3035df0d670f1b6ac Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 19 Feb 2026 22:38:27 -0500 Subject: [PATCH 204/548] Docs --- docs/config.md | 26 +++++++++++++++++++++----- server/server.yml | 13 ++++++++++--- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/config.md b/docs/config.md index f6e27f60..188dfbe9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -152,12 +152,28 @@ by setting the `database-url` option to a PostgreSQL connection string: database-url: "postgres://user:pass@host:5432/ntfy" ``` -When `database-url` is set, ntfy will use PostgreSQL for the web push subscription store instead of SQLite. The -`web-push-file` option is not required in this case. Support for PostgreSQL for the message cache and user manager -will be added in future releases. +When `database-url` is set, ntfy will use PostgreSQL for all database-backed stores (message cache, user manager, +and web push subscriptions) instead of SQLite. The `cache-file`, `auth-file`, and `web-push-file` options are not +required in this case. You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`. +### Connection pool settings +You can tune the connection pool by appending query parameters to the database URL: + +| Parameter | Default | Description | +|---|---|---| +| `pool_max_conns` | 25 | Maximum number of open connections to the database | +| `pool_max_idle_conns` | - | Maximum number of idle connections in the pool | +| `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) | +| `pool_conn_max_idle_time` | - | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`) | + +Example: + +```yaml +database-url: "postgres://user:pass@host:5432/ntfy?pool_max_conns=50&pool_conn_max_idle_time=5m" +``` + ## Attachments If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). @@ -1161,7 +1177,7 @@ a database to keep track of the browser's subscriptions, and an admin email addr - `web-push-expiry-warning-duration` defines the duration after which unused subscriptions are sent a warning (default is `55d`) - `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`) -Alternatively, you can use PostgreSQL instead of SQLite for the web push subscription store by setting `database-url` +Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url` (see [PostgreSQL database](#postgresql-database)). Limitations: @@ -1773,7 +1789,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | | `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | | `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). | -| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for database-backed stores instead of SQLite. Currently applies to the web push store. See [PostgreSQL database](#postgresql-database). | +| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for all database-backed stores (message cache, user manager, web push) instead of SQLite. See [PostgreSQL database](#postgresql-database). | | `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If 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. See [message cache](#message-cache). | | `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | | `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | diff --git a/server/server.yml b/server/server.yml index 63728ae2..28675806 100644 --- a/server/server.yml +++ b/server/server.yml @@ -38,11 +38,18 @@ # # firebase-key-file: -# If "database-url" is set, ntfy will use PostgreSQL for database-backed stores instead of SQLite. -# Currently this applies to the web push subscription store. Message cache and user manager support -# will be added in future releases. When set, the "web-push-file" option is not required. +# If "database-url" is set, ntfy will use PostgreSQL for all database-backed stores (message cache, +# user manager, and web push subscriptions) instead of SQLite. When set, the "cache-file", +# "auth-file", and "web-push-file" options are not required. +# +# You can append connection pool parameters as query parameters: +# pool_max_conns=25 - Maximum number of open connections (default: 25) +# pool_max_idle_conns=N - Maximum number of idle connections +# pool_conn_max_lifetime=5m - Maximum lifetime of a connection (Go duration) +# pool_conn_max_idle_time=1m - Maximum idle time of a connection (Go duration) # # database-url: "postgres://user:pass@host:5432/ntfy" +# database-url: "postgres://user:pass@host:5432/ntfy?pool_max_conns=50" # 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. From 209d5a4c62a6792e0db043df5c1ace83d87395e3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 19 Feb 2026 22:42:08 -0500 Subject: [PATCH 205/548] Update parallelism --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index b6102814..3683ab3c 100644 --- a/Makefile +++ b/Makefile @@ -268,10 +268,10 @@ check: test web-fmt-check fmt-check vet web-lint lint staticcheck checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck test: .PHONY - go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -parallel 3 $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') testv: .PHONY - go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -parallel 3 $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') race: .PHONY go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') From 039d555689ee1a57112fb7a9469024fc4a303d26 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 20 Feb 2026 08:19:25 -0500 Subject: [PATCH 206/548] Fix tests --- docs/config.md | 2 +- message/store_postgres_test.go | 3 +++ postgres/postgres.go | 6 +++--- server/server.go | 3 +++ server/server.yml | 2 +- server/server_test.go | 14 +++++++++----- user/manager_test.go | 9 ++++++--- user/store_postgres_test.go | 3 +++ webpush/store_postgres_test.go | 3 +++ 9 files changed, 32 insertions(+), 13 deletions(-) diff --git a/docs/config.md b/docs/config.md index 188dfbe9..ff47a7fb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -163,7 +163,7 @@ You can tune the connection pool by appending query parameters to the database U | Parameter | Default | Description | |---|---|---| -| `pool_max_conns` | 25 | Maximum number of open connections to the database | +| `pool_max_conns` | 10 | Maximum number of open connections to the database | | `pool_max_idle_conns` | - | Maximum number of idle connections in the pool | | `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) | | `pool_conn_max_idle_time` | - | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`) | diff --git a/message/store_postgres_test.go b/message/store_postgres_test.go index ebe60c70..951d571f 100644 --- a/message/store_postgres_test.go +++ b/message/store_postgres_test.go @@ -21,6 +21,9 @@ func newTestPostgresStore(t *testing.T) message.Store { u, err := url.Parse(dsn) require.Nil(t, err) q := u.Query() + q.Set("pool_max_conns", "2") + u.RawQuery = q.Encode() + dsn = u.String() q.Set("search_path", schema) u.RawQuery = q.Encode() schemaDSN := u.String() diff --git a/postgres/postgres.go b/postgres/postgres.go index 94913f58..437b413e 100644 --- a/postgres/postgres.go +++ b/postgres/postgres.go @@ -10,10 +10,10 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) -const defaultMaxOpenConns = 25 +const defaultMaxOpenConns = 10 // OpenDB opens a PostgreSQL database connection pool from a DSN string. It supports custom -// query parameters for pool configuration: pool_max_conns (default 25), pool_max_idle_conns, +// query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns, // pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from // the DSN before passing it to the driver. func OpenDB(dsn string) (*sql.DB, error) { @@ -54,7 +54,7 @@ func OpenDB(dsn string) (*sql.DB, error) { db.SetConnMaxIdleTime(connMaxIdleTime) } if err := db.Ping(); err != nil { - return nil, err + return nil, fmt.Errorf("ping failed: %w", err) } return db, nil } diff --git a/server/server.go b/server/server.go index 243a5094..8da588ff 100644 --- a/server/server.go +++ b/server/server.go @@ -425,6 +425,9 @@ func (s *Server) closeDatabases() { if s.webPush != nil { s.webPush.Close() } + if s.db != nil { + s.db.Close() + } } // handle is the main entry point for all HTTP requests diff --git a/server/server.yml b/server/server.yml index 28675806..b3f5e11e 100644 --- a/server/server.yml +++ b/server/server.yml @@ -43,7 +43,7 @@ # "auth-file", and "web-push-file" options are not required. # # You can append connection pool parameters as query parameters: -# pool_max_conns=25 - Maximum number of open connections (default: 25) +# pool_max_conns=10 - Maximum number of open connections (default: 10) # pool_max_idle_conns=N - Maximum number of idle connections # pool_conn_max_lifetime=5m - Maximum lifetime of a connection (Go duration) # pool_conn_max_idle_time=1m - Maximum idle time of a connection (Go duration) diff --git a/server/server_test.go b/server/server_test.go index 88bc1d5a..b1eacc8f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4134,14 +4134,17 @@ func forEachBackend(t *testing.T, f func(t *testing.T, databaseURL string)) { t.Skip("NTFY_TEST_DATABASE_URL not set") } schema := fmt.Sprintf("test_%s", util.RandomString(10)) - setupDB, err := postgres.OpenDB(dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) u, err := url.Parse(dsn) require.Nil(t, err) q := u.Query() + q.Set("pool_max_conns", "2") + u.RawQuery = q.Encode() + dsn = u.String() + setupDB, err := postgres.OpenDB(dsn) + require.Nil(t, err, "failed to open postgres: %s", err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) q.Set("search_path", schema) u.RawQuery = q.Encode() schemaDSN := u.String() @@ -4188,6 +4191,7 @@ func newTestConfigWithAuthFile(t *testing.T, databaseURL string) *Config { func newTestServer(t *testing.T, config *Config) *Server { server, err := New(config) require.Nil(t, err) + t.Cleanup(server.closeDatabases) return server } diff --git a/user/manager_test.go b/user/manager_test.go index 133c7b05..139eecb3 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -39,14 +39,17 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newStore newStoreFunc)) { t.Skip("NTFY_TEST_DATABASE_URL not set") } schema := fmt.Sprintf("test_%s", util.RandomString(10)) + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("pool_max_conns", "2") + u.RawQuery = q.Encode() + dsn = u.String() setupDB, err := postgres.OpenDB(dsn) require.Nil(t, err) _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) require.Nil(t, err) require.Nil(t, setupDB.Close()) - u, err := url.Parse(dsn) - require.Nil(t, err) - q := u.Query() q.Set("search_path", schema) u.RawQuery = q.Encode() schemaDSN := u.String() diff --git a/user/store_postgres_test.go b/user/store_postgres_test.go index ea222539..fafa62e9 100644 --- a/user/store_postgres_test.go +++ b/user/store_postgres_test.go @@ -21,6 +21,9 @@ func newTestPostgresStore(t *testing.T) user.Store { u, err := url.Parse(dsn) require.Nil(t, err) q := u.Query() + q.Set("pool_max_conns", "2") + u.RawQuery = q.Encode() + dsn = u.String() q.Set("search_path", schema) u.RawQuery = q.Encode() schemaDSN := u.String() diff --git a/webpush/store_postgres_test.go b/webpush/store_postgres_test.go index bacd8163..bdcc7a8e 100644 --- a/webpush/store_postgres_test.go +++ b/webpush/store_postgres_test.go @@ -21,6 +21,9 @@ func newTestPostgresStore(t *testing.T) webpush.Store { u, err := url.Parse(dsn) require.Nil(t, err) q := u.Query() + q.Set("pool_max_conns", "2") + u.RawQuery = q.Encode() + dsn = u.String() q.Set("search_path", schema) u.RawQuery = q.Encode() schemaDSN := u.String() From e818b063f7f302aef6758255592495e897122dfe Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 20 Feb 2026 08:25:32 -0500 Subject: [PATCH 207/548] Fmt --- server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index 8da588ff..bf19b3ba 100644 --- a/server/server.go +++ b/server/server.go @@ -47,7 +47,7 @@ import ( // Server is the main server, providing the UI and API for ntfy type Server struct { config *Config - db *sql.DB // Shared PostgreSQL connection pool, nil when using SQLite + db *sql.DB // Shared PostgreSQL connection pool, nil when using SQLite httpServer *http.Server httpsServer *http.Server httpMetricsServer *http.Server From a4c836b53193ee080e6d35bc4ffdbea4c1faffa7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 20 Feb 2026 15:36:12 -0500 Subject: [PATCH 208/548] Refine --- cmd/serve.go | 21 +++++++---- cmd/user.go | 6 +-- postgres/postgres.go => db/db.go | 6 +-- db/test/test.go | 63 ++++++++++++++++++++++++++++++++ message/store.go | 7 ---- message/store_postgres_test.go | 41 +++------------------ server/server.go | 26 ++++++------- server/server_test.go | 31 +--------------- user/manager_test.go | 36 +++--------------- user/store_postgres_test.go | 38 ++----------------- webpush/store_postgres_test.go | 38 ++----------------- 11 files changed, 113 insertions(+), 200 deletions(-) rename postgres/postgres.go => db/db.go (92%) create mode 100644 db/test/test.go diff --git a/cmd/serve.go b/cmd/serve.go index 33dc838d..62c88b96 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -282,7 +282,9 @@ func execServe(c *cli.Context) error { } // Check values - if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { + if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") { + return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set") + } else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { return errors.New("if set, FCM key file must exist") } else if firebaseKeyFile != "" && !server.FirebaseAvailable { return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)") @@ -414,6 +416,15 @@ func execServe(c *cli.Context) error { payments.Setup(stripeSecretKey) } + // Parse Twilio template + var twilioCallFormatTemplate *template.Template + if twilioCallFormat != "" { + twilioCallFormatTemplate, err = template.New("").Parse(twilioCallFormat) + if err != nil { + return fmt.Errorf("failed to parse twilio-call-format template: %w", err) + } + } + // Add default forbidden topics disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...) @@ -461,13 +472,7 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService - if twilioCallFormat != "" { - tmpl, err := template.New("twiml").Parse(twilioCallFormat) - if err != nil { - return fmt.Errorf("failed to parse twilio-call-format template: %w", err) - } - conf.TwilioCallFormat = tmpl - } + conf.TwilioCallFormat = twilioCallFormatTemplate conf.MessageSizeLimit = int(messageSizeLimit) conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit diff --git a/cmd/user.go b/cmd/user.go index e3d91cbe..f2c82a6c 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -11,7 +11,7 @@ import ( "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/v2/postgres" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -380,11 +380,11 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { } var store user.Store if databaseURL != "" { - db, dbErr := postgres.OpenDB(databaseURL) + pool, dbErr := db.Open(databaseURL) if dbErr != nil { return nil, dbErr } - store, err = user.NewPostgresStore(db) + store, err = user.NewPostgresStore(pool) } else if authFile != "" { if !util.FileExists(authFile) { return nil, errors.New("auth-file does not exist; please start the server at least once to create it") diff --git a/postgres/postgres.go b/db/db.go similarity index 92% rename from postgres/postgres.go rename to db/db.go index 437b413e..60807b4a 100644 --- a/postgres/postgres.go +++ b/db/db.go @@ -1,4 +1,4 @@ -package postgres +package db import ( "database/sql" @@ -12,11 +12,11 @@ import ( const defaultMaxOpenConns = 10 -// OpenDB opens a PostgreSQL database connection pool from a DSN string. It supports custom +// Open opens a PostgreSQL database connection pool from a DSN string. It supports custom // query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns, // pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from // the DSN before passing it to the driver. -func OpenDB(dsn string) (*sql.DB, error) { +func Open(dsn string) (*sql.DB, error) { u, err := url.Parse(dsn) if err != nil { return nil, fmt.Errorf("invalid database URL: %w", err) diff --git a/db/test/test.go b/db/test/test.go new file mode 100644 index 00000000..07a2f9a9 --- /dev/null +++ b/db/test/test.go @@ -0,0 +1,63 @@ +package dbtest + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/util" +) + +const testPoolMaxConns = "2" + +// CreateTestSchema 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 CreateTestSchema(t *testing.T) string { + t.Helper() + dsn := os.Getenv("NTFY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("NTFY_TEST_DATABASE_URL not set") + } + schema := fmt.Sprintf("test_%s", util.RandomString(10)) + u, err := url.Parse(dsn) + require.Nil(t, err) + q := u.Query() + q.Set("pool_max_conns", testPoolMaxConns) + u.RawQuery = q.Encode() + dsn = u.String() + setupDB, err := db.Open(dsn) + require.Nil(t, err) + _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) + require.Nil(t, err) + require.Nil(t, setupDB.Close()) + q.Set("search_path", schema) + u.RawQuery = q.Encode() + schemaDSN := u.String() + t.Cleanup(func() { + cleanDB, err := db.Open(dsn) + if err == nil { + cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanDB.Close() + } + }) + return schemaDSN +} + +// CreateTestDB creates a temporary PostgreSQL schema and returns an open *sql.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 CreateTestDB(t *testing.T) *sql.DB { + t.Helper() + schemaDSN := CreateTestSchema(t) + testDB, err := db.Open(schemaDSN) + require.Nil(t, err) + t.Cleanup(func() { + testDB.Close() + }) + return testDB +} diff --git a/message/store.go b/message/store.go index eeb34739..1351b823 100644 --- a/message/store.go +++ b/message/store.go @@ -4,7 +4,6 @@ import ( "database/sql" "encoding/json" "errors" - "fmt" "net/netip" "strings" "sync" @@ -614,9 +613,3 @@ func readMessage(rows *sql.Rows) (*model.Message, error) { Encoding: encoding, }, nil } - -// Ensure commonStore implements Store -var _ Store = (*commonStore)(nil) - -// Needed by store.go but not part of Store interface; unused import guard -var _ = fmt.Sprintf diff --git a/message/store_postgres_test.go b/message/store_postgres_test.go index 951d571f..3dec51a2 100644 --- a/message/store_postgres_test.go +++ b/message/store_postgres_test.go @@ -1,49 +1,18 @@ package message_test import ( - "fmt" - "net/url" - "os" "testing" - "github.com/stretchr/testify/require" + dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/message" - "heckel.io/ntfy/v2/postgres" - "heckel.io/ntfy/v2/util" + + "github.com/stretchr/testify/require" ) func newTestPostgresStore(t *testing.T) message.Store { - dsn := os.Getenv("NTFY_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") - } - schema := fmt.Sprintf("test_%s", util.RandomString(10)) - u, err := url.Parse(dsn) + testDB := dbtest.CreateTestDB(t) + store, err := message.NewPostgresStore(testDB, 0, 0) require.Nil(t, err) - q := u.Query() - q.Set("pool_max_conns", "2") - u.RawQuery = q.Encode() - dsn = u.String() - q.Set("search_path", schema) - u.RawQuery = q.Encode() - schemaDSN := u.String() - setupDB, err := postgres.OpenDB(dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - db, err := postgres.OpenDB(schemaDSN) - require.Nil(t, err) - store, err := message.NewPostgresStore(db, 0, 0) - require.Nil(t, err) - t.Cleanup(func() { - store.Close() - cleanDB, err := postgres.OpenDB(dsn) - if err == nil { - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() - } - }) return store } diff --git a/server/server.go b/server/server.go index bf19b3ba..b8d3a18b 100644 --- a/server/server.go +++ b/server/server.go @@ -33,11 +33,11 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/payments" - "heckel.io/ntfy/v2/postgres" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util/sprig" @@ -179,22 +179,22 @@ func New(conf *Config) (*Server, error) { stripe = newStripeAPI() } // Open shared PostgreSQL connection pool if configured - var db *sql.DB + var pool *sql.DB if conf.DatabaseURL != "" { var err error - db, err = postgres.OpenDB(conf.DatabaseURL) + pool, err = db.Open(conf.DatabaseURL) if err != nil { return nil, err } } - messageCache, err := createMessageCache(conf, db) + messageCache, err := createMessageCache(conf, pool) if err != nil { return nil, err } var wp webpush.Store if conf.WebPushPublicKey != "" { - if db != nil { - wp, err = webpush.NewPostgresStore(db) + if pool != nil { + wp, err = webpush.NewPostgresStore(pool) } else { wp, err = webpush.NewSQLiteStore(conf.WebPushFile, conf.WebPushStartupQueries) } @@ -222,7 +222,7 @@ func New(conf *Config) (*Server, error) { } } var userManager *user.Manager - if conf.AuthFile != "" || db != nil { + if conf.AuthFile != "" || pool != nil { authConfig := &user.Config{ Filename: conf.AuthFile, DatabaseURL: conf.DatabaseURL, @@ -236,8 +236,8 @@ func New(conf *Config) (*Server, error) { QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } var store user.Store - if db != nil { - store, err = user.NewPostgresStore(db) + if pool != nil { + store, err = user.NewPostgresStore(pool) } else { store, err = user.NewSQLiteStore(conf.AuthFile, conf.AuthStartupQueries) } @@ -265,7 +265,7 @@ func New(conf *Config) (*Server, error) { } s := &Server{ config: conf, - db: db, + db: pool, messageCache: messageCache, webPush: wp, fileCache: fileCache, @@ -282,11 +282,11 @@ func New(conf *Config) (*Server, error) { return s, nil } -func createMessageCache(conf *Config, db *sql.DB) (message.Store, error) { +func createMessageCache(conf *Config, pool *sql.DB) (message.Store, error) { if conf.CacheDuration == 0 { return message.NewNopStore() - } else if db != nil { - return message.NewPostgresStore(db, conf.CacheBatchSize, conf.CacheBatchTimeout) + } else if pool != nil { + return message.NewPostgresStore(pool, conf.CacheBatchSize, conf.CacheBatchTimeout) } else if conf.CacheFile != "" { return message.NewSQLiteStore(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false) } diff --git a/server/server_test.go b/server/server_test.go index b1eacc8f..2acdbac1 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -13,7 +13,6 @@ import ( "net/http" "net/http/httptest" "net/netip" - "net/url" "os" "path/filepath" "runtime/debug" @@ -25,10 +24,10 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" + dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/model" - "heckel.io/ntfy/v2/postgres" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -4129,33 +4128,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, databaseURL string)) { f(t, "") }) t.Run("postgres", func(t *testing.T) { - dsn := os.Getenv("NTFY_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("NTFY_TEST_DATABASE_URL not set") - } - schema := fmt.Sprintf("test_%s", util.RandomString(10)) - u, err := url.Parse(dsn) - require.Nil(t, err) - q := u.Query() - q.Set("pool_max_conns", "2") - u.RawQuery = q.Encode() - dsn = u.String() - setupDB, err := postgres.OpenDB(dsn) - require.Nil(t, err, "failed to open postgres: %s", err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - q.Set("search_path", schema) - u.RawQuery = q.Encode() - schemaDSN := u.String() - t.Cleanup(func() { - cleanDB, _ := postgres.OpenDB(dsn) - if cleanDB != nil { - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() - } - }) - f(t, schemaDSN) + f(t, dbtest.CreateTestSchema(t)) }) } diff --git a/user/manager_test.go b/user/manager_test.go index 139eecb3..83f21511 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "net/netip" - "net/url" - "os" "path/filepath" "strings" "testing" @@ -13,7 +11,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" - "heckel.io/ntfy/v2/postgres" + "heckel.io/ntfy/v2/db" + dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/util" ) @@ -34,36 +33,11 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newStore newStoreFunc)) { }) }) t.Run("postgres", func(t *testing.T) { - dsn := os.Getenv("NTFY_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("NTFY_TEST_DATABASE_URL not set") - } - schema := fmt.Sprintf("test_%s", util.RandomString(10)) - u, err := url.Parse(dsn) - require.Nil(t, err) - q := u.Query() - q.Set("pool_max_conns", "2") - u.RawQuery = q.Encode() - dsn = u.String() - setupDB, err := postgres.OpenDB(dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - q.Set("search_path", schema) - u.RawQuery = q.Encode() - schemaDSN := u.String() - t.Cleanup(func() { - cleanDB, _ := postgres.OpenDB(dsn) - if cleanDB != nil { - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() - } - }) + schemaDSN := dbtest.CreateTestSchema(t) f(t, func() Store { - db, err := postgres.OpenDB(schemaDSN) + pool, err := db.Open(schemaDSN) require.Nil(t, err) - store, err := NewPostgresStore(db) + store, err := NewPostgresStore(pool) require.Nil(t, err) return store }) diff --git a/user/store_postgres_test.go b/user/store_postgres_test.go index fafa62e9..319e9aa8 100644 --- a/user/store_postgres_test.go +++ b/user/store_postgres_test.go @@ -1,49 +1,17 @@ package user_test import ( - "fmt" - "net/url" - "os" "testing" "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/postgres" + dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" ) func newTestPostgresStore(t *testing.T) user.Store { - dsn := os.Getenv("NTFY_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") - } - schema := fmt.Sprintf("test_%s", util.RandomString(10)) - u, err := url.Parse(dsn) + testDB := dbtest.CreateTestDB(t) + store, err := user.NewPostgresStore(testDB) require.Nil(t, err) - q := u.Query() - q.Set("pool_max_conns", "2") - u.RawQuery = q.Encode() - dsn = u.String() - q.Set("search_path", schema) - u.RawQuery = q.Encode() - schemaDSN := u.String() - setupDB, err := postgres.OpenDB(dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - db, err := postgres.OpenDB(schemaDSN) - require.Nil(t, err) - store, err := user.NewPostgresStore(db) - require.Nil(t, err) - t.Cleanup(func() { - store.Close() - cleanDB, err := postgres.OpenDB(dsn) - if err == nil { - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() - } - }) return store } diff --git a/webpush/store_postgres_test.go b/webpush/store_postgres_test.go index bdcc7a8e..4c675c82 100644 --- a/webpush/store_postgres_test.go +++ b/webpush/store_postgres_test.go @@ -1,49 +1,17 @@ package webpush_test import ( - "fmt" - "net/url" - "os" "testing" "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/postgres" - "heckel.io/ntfy/v2/util" + dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/webpush" ) func newTestPostgresStore(t *testing.T) webpush.Store { - dsn := os.Getenv("NTFY_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests") - } - schema := fmt.Sprintf("test_%s", util.RandomString(10)) - u, err := url.Parse(dsn) + testDB := dbtest.CreateTestDB(t) + store, err := webpush.NewPostgresStore(testDB) require.Nil(t, err) - q := u.Query() - q.Set("pool_max_conns", "2") - u.RawQuery = q.Encode() - dsn = u.String() - q.Set("search_path", schema) - u.RawQuery = q.Encode() - schemaDSN := u.String() - setupDB, err := postgres.OpenDB(dsn) - require.Nil(t, err) - _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) - require.Nil(t, err) - require.Nil(t, setupDB.Close()) - db, err := postgres.OpenDB(schemaDSN) - require.Nil(t, err) - store, err := webpush.NewPostgresStore(db) - require.Nil(t, err) - t.Cleanup(func() { - store.Close() - cleanDB, err := postgres.OpenDB(dsn) - if err == nil { - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() - } - }) return store } From eb6e1ac44a3a54225de417c4982678cd462fbb16 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 20 Feb 2026 15:57:47 -0500 Subject: [PATCH 209/548] Docs --- cmd/serve.go | 8 ++++---- db/db.go | 17 ++++++++++++----- docs/config.md | 21 +++++++++++++-------- docs/releases.md | 2 +- server/config.go | 4 ++-- server/server.yml | 7 ++++++- 6 files changed, 38 insertions(+), 21 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 62c88b96..313ec835 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -325,8 +325,8 @@ func execServe(c *cli.Context) error { return errors.New("if upstream-base-url is set, base-url must also be set") } else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL { return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications") - } else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") { - return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set") + } else if authFile == "" && databaseURL == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") { + return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file or database-url is not set") } else if enableSignup && !enableLogin { return errors.New("cannot set enable-signup without also setting enable-login") } else if requireLogin && !enableLogin { @@ -335,8 +335,8 @@ func execServe(c *cli.Context) error { return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") - } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { - return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set") + } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || (authFile == "" && databaseURL == "")) { + return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file (or database-url) must also be set") } else if messageSizeLimit > server.DefaultMessageSizeLimit { log.Warn("message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients") if messageSizeLimit > 5*1024*1024 { diff --git a/db/db.go b/db/db.go index 60807b4a..4d402c41 100644 --- a/db/db.go +++ b/db/db.go @@ -10,7 +10,14 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) -const defaultMaxOpenConns = 10 +const ( + paramMaxOpenConns = "pool_max_conns" + paramMaxIdleConns = "pool_max_idle_conns" + paramConnMaxLifetime = "pool_conn_max_lifetime" + paramConnMaxIdleTime = "pool_conn_max_idle_time" + + defaultMaxOpenConns = 10 +) // Open opens a PostgreSQL database connection pool from a DSN string. It supports custom // query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns, @@ -22,19 +29,19 @@ func Open(dsn string) (*sql.DB, error) { return nil, fmt.Errorf("invalid database URL: %w", err) } q := u.Query() - maxOpenConns, err := extractIntParam(q, "pool_max_conns", defaultMaxOpenConns) + maxOpenConns, err := extractIntParam(q, paramMaxOpenConns, defaultMaxOpenConns) if err != nil { return nil, err } - maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0) + maxIdleConns, err := extractIntParam(q, paramMaxIdleConns, 0) if err != nil { return nil, err } - connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0) + connMaxLifetime, err := extractDurationParam(q, paramConnMaxLifetime, 0) if err != nil { return nil, err } - connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0) + connMaxIdleTime, err := extractDurationParam(q, paramConnMaxIdleTime, 0) if err != nil { return nil, err } diff --git a/docs/config.md b/docs/config.md index ff47a7fb..e9cd7add 100644 --- a/docs/config.md +++ b/docs/config.md @@ -153,8 +153,12 @@ database-url: "postgres://user:pass@host:5432/ntfy" ``` When `database-url` is set, ntfy will use PostgreSQL for all database-backed stores (message cache, user manager, -and web push subscriptions) instead of SQLite. The `cache-file`, `auth-file`, and `web-push-file` options are not -required in this case. +and web push subscriptions) instead of SQLite. The `cache-file`, `auth-file`, and `web-push-file` options must not +be set in this case. + +Note that setting `database-url` implicitly enables authentication and access control (equivalent to setting +`auth-file` with SQLite). The default access is `read-write`, so anonymous users can still read and write to all +topics. To restrict access, set `auth-default-access` to `deny-all` (see [access control](#access-control)). You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`. @@ -215,14 +219,15 @@ and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization. -ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based backend. It implements two roles -(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). -Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access. +ntfy's auth implements two roles (`user` and `admin`) and per-topic `read` and `write` permissions using an +[access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). Access control entries can be applied +to users as well as the special everyone user (`*`), which represents anonymous API access. To set up auth, **configure the following options**: -* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested - location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used) +* `auth-file` is the user/access database (SQLite); it is created automatically if it doesn't already exist; suggested + location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used). Alternatively, if `database-url` is set, + auth is automatically enabled using PostgreSQL (see [PostgreSQL database](#postgresql-database)). * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance, you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)). @@ -1795,7 +1800,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | | `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) | | `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) | -| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). | +| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). | | `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | | `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) | | `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) | diff --git a/docs/releases.md b/docs/releases.md index fd71cc0d..aff0483e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1720,4 +1720,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Add PostgreSQL as an alternative database backend for the web push subscription store via `database-url` config option +* Add PostgreSQL as an alternative database backend for all stores (message cache, user manager, web push subscriptions) via `database-url` config option, using a single shared connection pool with configurable pool settings (`pool_max_conns`, `pool_max_idle_conns`, `pool_conn_max_lifetime`, `pool_conn_max_idle_time`) diff --git a/server/config.go b/server/config.go index 85a2c2b2..786f0d78 100644 --- a/server/config.go +++ b/server/config.go @@ -88,7 +88,6 @@ var ( // Config is the main config struct for the application. Use New to instantiate a default config struct. type Config struct { File string // Config file, only used for testing - DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy") BaseURL string ListenHTTP string ListenHTTPS string @@ -96,6 +95,7 @@ type Config struct { ListenUnixMode fs.FileMode KeyFile string CertFile string + DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy") FirebaseKeyFile string CacheFile string CacheDuration time.Duration @@ -193,7 +193,6 @@ type Config struct { func NewConfig() *Config { return &Config{ File: DefaultConfigFile, // Only used for testing - DatabaseURL: "", BaseURL: "", ListenHTTP: DefaultListenHTTP, ListenHTTPS: "", @@ -201,6 +200,7 @@ func NewConfig() *Config { ListenUnixMode: 0, KeyFile: "", CertFile: "", + DatabaseURL: "", FirebaseKeyFile: "", CacheFile: "", CacheDuration: DefaultCacheDuration, diff --git a/server/server.yml b/server/server.yml index b3f5e11e..389fae80 100644 --- a/server/server.yml +++ b/server/server.yml @@ -40,7 +40,10 @@ # If "database-url" is set, ntfy will use PostgreSQL for all database-backed stores (message cache, # user manager, and web push subscriptions) instead of SQLite. When set, the "cache-file", -# "auth-file", and "web-push-file" options are not required. +# "auth-file", and "web-push-file" options must not be set. +# +# Note: Setting "database-url" implicitly enables authentication and access control. +# The default access is "read-write" (see "auth-default-access"). # # You can append connection pool parameters as query parameters: # pool_max_conns=10 - Maximum number of open connections (default: 10) @@ -90,6 +93,8 @@ # If set, access to the ntfy server and API can be controlled on a granular level using # the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs. # +# Note: If "database-url" is set, auth is implicitly enabled and "auth-file" must not be set. +# # - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist # - auth-default-access defines the default/fallback access if no access control entry is found; it can be # set to "read-write" (default), "read-only", "write-only" or "deny-all". From 13a3062a7f388a43400523ce9bfccfbfac8cb95f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 20 Feb 2026 16:15:07 -0500 Subject: [PATCH 210/548] Re-add comments --- message/store.go | 5 ++++- message/store_sqlite.go | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/message/store.go b/message/store.go index 1351b823..ed6168fd 100644 --- a/message/store.go +++ b/message/store.go @@ -98,6 +98,7 @@ func newCommonStore(db *sql.DB, queries storeQueries, batchSize int, batchTimeou } // AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asynchronously. +// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor. func (c *commonStore) AddMessage(m *model.Message) error { if c.queue != nil { c.queue.Enqueue(m) @@ -376,6 +377,7 @@ func (c *commonStore) DeleteScheduledBySequenceID(topic, sequenceID string) ([]s return nil, err } defer tx.Rollback() + // First, get the message IDs of scheduled messages to be deleted rows, err := tx.Query(c.queries.selectScheduledMessageIDsBySeqID, topic, sequenceID) if err != nil { return nil, err @@ -392,7 +394,8 @@ func (c *commonStore) DeleteScheduledBySequenceID(topic, sequenceID string) ([]s if err := rows.Err(); err != nil { return nil, err } - rows.Close() + rows.Close() // Close rows before executing delete in same transaction + // Then delete the messages if _, err := tx.Exec(c.queries.deleteScheduledBySequenceID, topic, sequenceID); err != nil { return nil, err } diff --git a/message/store_sqlite.go b/message/store_sqlite.go index 566682b1..ebccd468 100644 --- a/message/store_sqlite.go +++ b/message/store_sqlite.go @@ -135,6 +135,11 @@ func NewNopStore() (Store, error) { } // createMemoryFilename creates a unique memory filename to use for the SQLite backend. +// From mattn/go-sqlite3: "Each connection to ":memory:" opens a brand new in-memory +// sql database, so if the stdlib's sql engine happens to open another connection and +// you've only specified ":memory:", that connection will see a brand new database. +// A workaround is to use "file::memory:?cache=shared" (or "file:foobar?mode=memory&cache=shared"). +// Every connection to this string will point to the same in-memory database." func createMemoryFilename() string { return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10)) } From a28d8e7924363446ae1ebd19fcb0a1c26dd18709 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 20 Feb 2026 16:36:41 -0500 Subject: [PATCH 211/548] Update docs, manual changes --- cmd/user.go | 2 +- db/db.go | 4 +- db/test/test.go | 6 +-- docs/config.md | 87 ++++++++++++++++++++++++-------------------- server/server.go | 4 +- user/manager_test.go | 2 +- 6 files changed, 57 insertions(+), 48 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index f2c82a6c..808920be 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -380,7 +380,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { } var store user.Store if databaseURL != "" { - pool, dbErr := db.Open(databaseURL) + pool, dbErr := db.OpenPostgres(databaseURL) if dbErr != nil { return nil, dbErr } diff --git a/db/db.go b/db/db.go index 4d402c41..e21b0590 100644 --- a/db/db.go +++ b/db/db.go @@ -19,11 +19,11 @@ const ( defaultMaxOpenConns = 10 ) -// Open opens a PostgreSQL database connection pool from a DSN string. It supports custom +// OpenPostgres opens a PostgreSQL database connection pool from a DSN string. It supports custom // query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns, // pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from // the DSN before passing it to the driver. -func Open(dsn string) (*sql.DB, error) { +func OpenPostgres(dsn string) (*sql.DB, error) { u, err := url.Parse(dsn) if err != nil { return nil, fmt.Errorf("invalid database URL: %w", err) diff --git a/db/test/test.go b/db/test/test.go index 07a2f9a9..18f36049 100644 --- a/db/test/test.go +++ b/db/test/test.go @@ -30,7 +30,7 @@ func CreateTestSchema(t *testing.T) string { q.Set("pool_max_conns", testPoolMaxConns) u.RawQuery = q.Encode() dsn = u.String() - setupDB, err := db.Open(dsn) + setupDB, err := db.OpenPostgres(dsn) require.Nil(t, err) _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) require.Nil(t, err) @@ -39,7 +39,7 @@ func CreateTestSchema(t *testing.T) string { u.RawQuery = q.Encode() schemaDSN := u.String() t.Cleanup(func() { - cleanDB, err := db.Open(dsn) + cleanDB, err := db.OpenPostgres(dsn) if err == nil { cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) cleanDB.Close() @@ -54,7 +54,7 @@ func CreateTestSchema(t *testing.T) string { func CreateTestDB(t *testing.T) *sql.DB { t.Helper() schemaDSN := CreateTestSchema(t) - testDB, err := db.Open(schemaDSN) + testDB, err := db.OpenPostgres(schemaDSN) require.Nil(t, err) t.Cleanup(func() { testDB.Close() diff --git a/docs/config.md b/docs/config.md index e9cd7add..5216d765 100644 --- a/docs/config.md +++ b/docs/config.md @@ -125,16 +125,59 @@ using Docker Compose (i.e. `docker-compose.yml`): command: serve ``` +## Database options +ntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions. +You can choose between **SQLite** and **PostgreSQL** as the database backend. + +### SQLite +By default, ntfy uses SQLite with separate database files for each store. This is the simplest setup and requires +no external dependencies: + +* `cache-file`: Database file for the [message cache](#message-cache). +* `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth. +* `web-push-file`: Database file for [web push](#web-push) subscriptions. + +### PostgreSQL +As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the +`database-url` option to a PostgreSQL connection string: + +```yaml +database-url: "postgres://user:pass@host:5432/ntfy" +``` + +When `database-url` is set, ntfy will use PostgreSQL for the [message cache](#message-cache), +[access control](#access-control), and [web push](#web-push) subscriptions instead of SQLite. The `cache-file`, +`auth-file`, and `web-push-file` options **must not** be set in this case. + +Note that setting `database-url` implicitly enables authentication and access control (equivalent to setting +`auth-file` with SQLite). The default access is `read-write`, so anonymous users can still read and write to all +topics. To restrict access, set `auth-default-access` to `deny-all` (see [access control](#access-control)). + +You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`. + +You can tune the PostgreSQL connection pool by appending query parameters to the database URL: + +| Parameter | Default | Description | +|---------------------------|---------|----------------------------------------------------------------------------------| +| `pool_max_conns` | 10 | Maximum number of open connections to the database | +| `pool_max_idle_conns` | - | Maximum number of idle connections in the pool | +| `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) | +| `pool_conn_max_idle_time` | - | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`) | + +Example: + +```yaml +database-url: "postgres://user:pass@host:5432/ntfy?pool_max_conns=50&pool_conn_max_idle_time=5m" +``` + ## Message cache If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve notifications that they may have missed. By default, ntfy keeps messages **in-memory for 12 hours**, which means that **cached messages do not survive an application -restart**. You can override this behavior using the following config settings: +restart**. You can override this behavior by setting `cache-file` (SQLite) or `database-url` (PostgreSQL). -* `cache-file`: if set, ntfy will store messages in a SQLite based cache (default is empty, which means in-memory cache). - **This is required if you'd like messages to be retained across restarts**. * `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`). You can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only @@ -144,40 +187,6 @@ the message to the subscribers. Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the [`since=` parameter](subscribe/api.md#fetch-cached-messages). -## PostgreSQL database -By default, ntfy uses SQLite for all database-backed stores. As an alternative, you can configure ntfy to use PostgreSQL -by setting the `database-url` option to a PostgreSQL connection string: - -```yaml -database-url: "postgres://user:pass@host:5432/ntfy" -``` - -When `database-url` is set, ntfy will use PostgreSQL for all database-backed stores (message cache, user manager, -and web push subscriptions) instead of SQLite. The `cache-file`, `auth-file`, and `web-push-file` options must not -be set in this case. - -Note that setting `database-url` implicitly enables authentication and access control (equivalent to setting -`auth-file` with SQLite). The default access is `read-write`, so anonymous users can still read and write to all -topics. To restrict access, set `auth-default-access` to `deny-all` (see [access control](#access-control)). - -You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`. - -### Connection pool settings -You can tune the connection pool by appending query parameters to the database URL: - -| Parameter | Default | Description | -|---|---|---| -| `pool_max_conns` | 10 | Maximum number of open connections to the database | -| `pool_max_idle_conns` | - | Maximum number of idle connections in the pool | -| `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) | -| `pool_conn_max_idle_time` | - | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`) | - -Example: - -```yaml -database-url: "postgres://user:pass@host:5432/ntfy?pool_max_conns=50&pool_conn_max_idle_time=5m" -``` - ## Attachments If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). @@ -227,7 +236,7 @@ To set up auth, **configure the following options**: * `auth-file` is the user/access database (SQLite); it is created automatically if it doesn't already exist; suggested location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used). Alternatively, if `database-url` is set, - auth is automatically enabled using PostgreSQL (see [PostgreSQL database](#postgresql-database)). + auth is automatically enabled using PostgreSQL (see [database options](#database-options)). * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance, you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)). @@ -1794,7 +1803,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | | `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | | `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). | -| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for all database-backed stores (message cache, user manager, web push) instead of SQLite. See [PostgreSQL database](#postgresql-database). | +| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for all database-backed stores (message cache, user manager, web push) instead of SQLite. See [database options](#database-options). | | `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If 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. See [message cache](#message-cache). | | `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | | `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | diff --git a/server/server.go b/server/server.go index b8d3a18b..c2950032 100644 --- a/server/server.go +++ b/server/server.go @@ -178,11 +178,11 @@ func New(conf *Config) (*Server, error) { if payments.Available && conf.StripeSecretKey != "" { stripe = newStripeAPI() } - // Open shared PostgreSQL connection pool if configured + // OpenPostgres shared PostgreSQL connection pool if configured var pool *sql.DB if conf.DatabaseURL != "" { var err error - pool, err = db.Open(conf.DatabaseURL) + pool, err = db.OpenPostgres(conf.DatabaseURL) if err != nil { return nil, err } diff --git a/user/manager_test.go b/user/manager_test.go index 83f21511..014bb149 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -35,7 +35,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newStore newStoreFunc)) { t.Run("postgres", func(t *testing.T) { schemaDSN := dbtest.CreateTestSchema(t) f(t, func() Store { - pool, err := db.Open(schemaDSN) + pool, err := db.OpenPostgres(schemaDSN) require.Nil(t, err) store, err := NewPostgresStore(pool) require.Nil(t, err) From 7c69a76345d1c825b21c64b28ea690bcd39725db Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 20 Feb 2026 16:56:47 -0500 Subject: [PATCH 212/548] Docs --- docs/config.md | 22 ++++++++++++++++++---- docs/releases.md | 2 +- server/server.yml | 15 ++++++++++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/config.md b/docs/config.md index 5216d765..52777cd8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -53,6 +53,16 @@ Here are a few working sample configs using a `/etc/ntfy/server.yml` file: behind-proxy: true ``` +=== "server.yml (PostgreSQL, behind proxy)" + ``` yaml + base-url: "https://ntfy.example.com" + listen-http: ":2586" + database-url: "postgres://ntfy:mypassword@db.example.com:5432/ntfy?sslmode=require" + attachment-cache-dir: "/var/cache/ntfy/attachments" + behind-proxy: true + auth-default-access: "deny-all" + ``` + === "server.yml (ntfy.sh config)" ``` yaml # All the things: Behind a proxy, Firebase, cache, attachments, @@ -137,7 +147,7 @@ no external dependencies: * `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth. * `web-push-file`: Database file for [web push](#web-push) subscriptions. -### PostgreSQL +### PostgreSQL (EXPERIMENTAL) As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the `database-url` option to a PostgreSQL connection string: @@ -155,7 +165,11 @@ topics. To restrict access, set `auth-default-access` to `deny-all` (see [access You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`. -You can tune the PostgreSQL connection pool by appending query parameters to the database URL: +The database URL supports the standard [PostgreSQL connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS) +as query parameters, such as `sslmode`, `connect_timeout`, `sslcert`, `sslkey`, `sslrootcert`, and `application_name`. +See the [pgx driver documentation](https://pkg.go.dev/github.com/jackc/pgx/v5) for the full list of supported parameters. + +In addition, ntfy supports the following custom query parameters to tune the connection pool: | Parameter | Default | Description | |---------------------------|---------|----------------------------------------------------------------------------------| @@ -167,7 +181,7 @@ You can tune the PostgreSQL connection pool by appending query parameters to the Example: ```yaml -database-url: "postgres://user:pass@host:5432/ntfy?pool_max_conns=50&pool_conn_max_idle_time=5m" +database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m" ``` ## Message cache @@ -1192,7 +1206,7 @@ a database to keep track of the browser's subscriptions, and an admin email addr - `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`) Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url` -(see [PostgreSQL database](#postgresql-database)). +(see [PostgreSQL database](#postgresql-experimental)). Limitations: diff --git a/docs/releases.md b/docs/releases.md index aff0483e..b5ce5f31 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1720,4 +1720,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Add PostgreSQL as an alternative database backend for all stores (message cache, user manager, web push subscriptions) via `database-url` config option, using a single shared connection pool with configurable pool settings (`pool_max_conns`, `pool_max_idle_conns`, `pool_conn_max_lifetime`, `pool_conn_max_idle_time`) +* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option diff --git a/server/server.yml b/server/server.yml index 389fae80..43cb5fb4 100644 --- a/server/server.yml +++ b/server/server.yml @@ -45,17 +45,25 @@ # Note: Setting "database-url" implicitly enables authentication and access control. # The default access is "read-write" (see "auth-default-access"). # -# You can append connection pool parameters as query parameters: +# The URL supports standard PostgreSQL parameters (sslmode, connect_timeout, sslcert, etc.), +# as well as ntfy-specific connection pool parameters: # pool_max_conns=10 - Maximum number of open connections (default: 10) # pool_max_idle_conns=N - Maximum number of idle connections # pool_conn_max_lifetime=5m - Maximum lifetime of a connection (Go duration) # pool_conn_max_idle_time=1m - Maximum idle time of a connection (Go duration) # -# database-url: "postgres://user:pass@host:5432/ntfy" -# database-url: "postgres://user:pass@host:5432/ntfy?pool_max_conns=50" +# See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS +# for the full list of supported PostgreSQL connection parameters. +# +# Examples: +# database-url: "postgres://user:pass@host:5432/ntfy" +# database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50" +# +# database-url: # 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). # # The "cache-duration" parameter defines the duration for which messages will be buffered # before they are deleted. This is required to support the "since=..." and "poll=1" parameter. @@ -215,6 +223,7 @@ # - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890 # - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890 # - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db +# Not required if "database-url" is set (subscriptions are stored in PostgreSQL instead). # - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com # - web-push-startup-queries is an optional list of queries to run on startup # - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d) From 2e499389fc7068d73b8249d97ee7e28c6b2a86c6 Mon Sep 17 00:00:00 2001 From: Ivan Uzkikh Date: Sat, 21 Feb 2026 00:57:04 +0100 Subject: [PATCH 213/548] fix(smtp): preserve
    line breaks in HTML emails HTML-only emails (e.g. from Synology DSM 7.3 Task Scheduler) use
    tags for line breaks. The existing implementation passed the raw HTML body to bluemonday with AddSpaceWhenStrippingTag(true), which replaced every tag including
    with a space, causing all content to appear on a single unreadable line. Fix: convert
    tags to newlines before stripping HTML, so line break semantics are preserved in the notification body. Resolves the gap noted in #690 ("very sub-par" HTML support). --- server/smtp_server.go | 4 ++++ server/smtp_server_test.go | 41 ++++++++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/server/smtp_server.go b/server/smtp_server.go index ee28efc2..02414d3b 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -33,6 +33,7 @@ var ( var ( onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`) consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`) + htmlLineBreakRegex = regexp.MustCompile(`(?i)`) ) const ( @@ -327,6 +328,9 @@ func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error) if err != nil { return "", err } + // Convert
    tags to newlines before stripping HTML, so that line breaks + // in HTML emails (e.g. from Synology DSM, and other appliances) are preserved. + body = htmlLineBreakRegex.ReplaceAllString(body, "\n") stripped := bluemonday. StrictPolicy(). AddSpaceWhenStrippingTag(true). diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go index d4178a40..8b614819 100644 --- a/server/smtp_server_test.go +++ b/server/smtp_server_test.go @@ -694,7 +694,8 @@ home automation setup Now the light is on If you don't want to receive this message anymore, stop the push - services in your FRITZ!Box . + services in your FRITZ!Box . + Here you can see the active push services: "System > Push Service". This mail has ben sent by your FRITZ!Box automatically.` @@ -1354,9 +1355,11 @@ Congratulations! You have successfully set up the email notification on Synology s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/synology", r.URL.Path) require.Equal(t, "[Synology NAS] Test Message from Litts_NAS", r.Header.Get("Title")) - actual := readAll(t, r.Body) - expected := `Congratulations! You have successfully set up the email notification on Synology_NAS. For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/. (If you cannot connect to the server, please contact the administrator.) From Synology_NAS` - require.Equal(t, expected, actual) + expected := "Congratulations! You have successfully set up the email notification on Synology_NAS.\n" + + "For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/.\n" + + "(If you cannot connect to the server, please contact the administrator.)\n\n" + + "From Synology_NAS" + require.Equal(t, expected, readAll(t, r.Body)) }) conf.SMTPServerDomain = "mydomain.me" conf.SMTPServerAddrPrefix = "" @@ -1365,6 +1368,36 @@ Congratulations! You have successfully set up the email notification on Synology writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") } +func TestSmtpBackend_HTMLEmail_BrTagsPreserved(t *testing.T) { + email := `EHLO example.com +MAIL FROM: nas@example.com +RCPT TO: ntfy-alerts@ntfy.sh +DATA +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: 8bit +Subject: Task Scheduler: daily-backup + +Task Scheduler has completed a scheduled task.

    Task: daily-backup
    Start time: Mon, 01 Jan 2026 02:00:00 +0000
    Stop time: Mon, 01 Jan 2024 02:03:00 +0000
    Current status: 0 (Normal)
    Standard output/error:
    OK

    From MyNAS +. +` + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/alerts", r.URL.Path) + require.Equal(t, "Task Scheduler: daily-backup", r.Header.Get("Title")) + expected := "Task Scheduler has completed a scheduled task.\n\n" + + "Task: daily-backup\n" + + "Start time: Mon, 01 Jan 2026 02:00:00 +0000\n" + + "Stop time: Mon, 01 Jan 2024 02:03:00 +0000\n" + + "Current status: 0 (Normal)\n" + + "Standard output/error:\n" + + "OK\n\n" + + "From MyNAS" + require.Equal(t, expected, readAll(t, r.Body)) + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + func TestSmtpBackend_PlaintextWithToken(t *testing.T) { email := `EHLO example.com MAIL FROM: phil@example.com From 4e22e7f4c80bf3c40a25772b70d2baa7af2c0d50 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Feb 2026 09:58:52 -0500 Subject: [PATCH 214/548] Release notes --- docs/releases.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index f291bea2..919b75b7 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1698,6 +1698,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.18.x (UNRELEASED) + +**Bug fixes + maintenance:** + +* Preserve `
    ` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) + ### ntfy Android v1.23.x (UNRELEASED) **Features:** From b82e1c3915cb90aaa34670b8892c1d33c23e5260 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Feb 2026 10:02:27 -0500 Subject: [PATCH 215/548] Release notes --- docs/releases.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 5e810663..4c5046b8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1700,6 +1700,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ### ntfy server v2.18.x (UNRELEASED) +**Features:** + +* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114), thanks to [@brettinternet](https://github.com/brettinternet) for reporting) + **Bug fixes + maintenance:** * Preserve `
    ` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) @@ -1721,9 +1725,3 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting) * Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting) * Fix crash in settings when fragment is detached during backup/restore or log operations - -### ntfy server v2.12.x (UNRELEASED) - -**Features:** - -* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option From c76e39bb0ef623c721b2364dacfc5ce50dbb2443 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Feb 2026 10:36:09 -0500 Subject: [PATCH 216/548] Manual updates --- db/test/test.go | 10 +++++----- message/store_postgres_test.go | 2 +- server/server_test.go | 2 +- user/manager_test.go | 2 +- user/store_postgres_test.go | 2 +- webpush/store_postgres_test.go | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/db/test/test.go b/db/test/test.go index 18f36049..d843becb 100644 --- a/db/test/test.go +++ b/db/test/test.go @@ -14,10 +14,10 @@ import ( const testPoolMaxConns = "2" -// CreateTestSchema creates a temporary PostgreSQL schema and returns the DSN pointing to it. +// 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 CreateTestSchema(t *testing.T) string { +func CreateTestPostgresSchema(t *testing.T) string { t.Helper() dsn := os.Getenv("NTFY_TEST_DATABASE_URL") if dsn == "" { @@ -48,12 +48,12 @@ func CreateTestSchema(t *testing.T) string { return schemaDSN } -// CreateTestDB creates a temporary PostgreSQL schema and returns an open *sql.DB connection to it. +// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *sql.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 CreateTestDB(t *testing.T) *sql.DB { +func CreateTestPostgres(t *testing.T) *sql.DB { t.Helper() - schemaDSN := CreateTestSchema(t) + schemaDSN := CreateTestPostgresSchema(t) testDB, err := db.OpenPostgres(schemaDSN) require.Nil(t, err) t.Cleanup(func() { diff --git a/message/store_postgres_test.go b/message/store_postgres_test.go index 3dec51a2..522620db 100644 --- a/message/store_postgres_test.go +++ b/message/store_postgres_test.go @@ -10,7 +10,7 @@ import ( ) func newTestPostgresStore(t *testing.T) message.Store { - testDB := dbtest.CreateTestDB(t) + testDB := dbtest.CreateTestPostgres(t) store, err := message.NewPostgresStore(testDB, 0, 0) require.Nil(t, err) return store diff --git a/server/server_test.go b/server/server_test.go index 2acdbac1..46881593 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4128,7 +4128,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, databaseURL string)) { f(t, "") }) t.Run("postgres", func(t *testing.T) { - f(t, dbtest.CreateTestSchema(t)) + f(t, dbtest.CreateTestPostgresSchema(t)) }) } diff --git a/user/manager_test.go b/user/manager_test.go index 014bb149..d903332f 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -33,7 +33,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newStore newStoreFunc)) { }) }) t.Run("postgres", func(t *testing.T) { - schemaDSN := dbtest.CreateTestSchema(t) + schemaDSN := dbtest.CreateTestPostgresSchema(t) f(t, func() Store { pool, err := db.OpenPostgres(schemaDSN) require.Nil(t, err) diff --git a/user/store_postgres_test.go b/user/store_postgres_test.go index 319e9aa8..e3902ea2 100644 --- a/user/store_postgres_test.go +++ b/user/store_postgres_test.go @@ -9,7 +9,7 @@ import ( ) func newTestPostgresStore(t *testing.T) user.Store { - testDB := dbtest.CreateTestDB(t) + testDB := dbtest.CreateTestPostgres(t) store, err := user.NewPostgresStore(testDB) require.Nil(t, err) return store diff --git a/webpush/store_postgres_test.go b/webpush/store_postgres_test.go index 4c675c82..1b2d43d2 100644 --- a/webpush/store_postgres_test.go +++ b/webpush/store_postgres_test.go @@ -9,7 +9,7 @@ import ( ) func newTestPostgresStore(t *testing.T) webpush.Store { - testDB := dbtest.CreateTestDB(t) + testDB := dbtest.CreateTestPostgres(t) store, err := webpush.NewPostgresStore(testDB) require.Nil(t, err) return store From 4b6979aa89bc1bb05082fc16c059d809508440b3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Feb 2026 10:42:34 -0500 Subject: [PATCH 217/548] Move model constants --- server/actions.go | 2 +- server/file_cache.go | 3 ++- server/server.go | 48 +++++++++++++++++----------------- server/server_account.go | 3 ++- server/server_account_test.go | 11 ++++---- server/server_firebase.go | 8 +++--- server/server_firebase_test.go | 8 +++--- server/server_manager_test.go | 3 ++- server/server_payments_test.go | 5 ++-- server/server_test.go | 30 ++++++++++----------- server/smtp_server.go | 2 +- server/types.go | 40 ++-------------------------- 12 files changed, 66 insertions(+), 97 deletions(-) diff --git a/server/actions.go b/server/actions.go index dc4a6b43..f741c6a6 100644 --- a/server/actions.go +++ b/server/actions.go @@ -136,7 +136,7 @@ func (p *actionParser) Parse() ([]*model.Action, error) { // and then uses populateAction to interpret the keys/values. The function terminates // when EOF or ";" is reached. func (p *actionParser) parseAction() (*model.Action, error) { - a := newAction() + a := model.NewAction() section := 0 for { key, value, last, err := p.parseSection() diff --git a/server/file_cache.go b/server/file_cache.go index 758d38ee..a1803724 100644 --- a/server/file_cache.go +++ b/server/file_cache.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/util" "io" "os" @@ -13,7 +14,7 @@ import ( ) var ( - fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength)) + fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength)) errInvalidFileID = errors.New("invalid file ID") errFileExists = errors.New("file exists") ) diff --git a/server/server.go b/server/server.go index c2950032..396355e1 100644 --- a/server/server.go +++ b/server/server.go @@ -770,7 +770,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) // - avoid abuse (e.g. 1 uploader, 1k downloaders) // - and also uses the higher bandwidth limits of a paying user m, err := s.messageCache.Message(messageID) - if errors.Is(err, errMessageNotFound) { + if errors.Is(err, model.ErrMessageNotFound) { if s.config.CacheBatchTimeout > 0 { // Strange edge case: If we immediately after upload request the file (the web app does this for images), // and messages are persisted asynchronously, retry fetching from the database @@ -834,7 +834,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess if err != nil { return nil, err } - m := newDefaultMessage(t.ID, "") + m := model.NewDefaultMessage(t.ID, "") cache, firebase, email, call, template, unifiedpush, priorityStr, e := s.parsePublishParams(r, m) if e != nil { return nil, e.With(t) @@ -977,11 +977,11 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - return s.handleActionMessage(w, r, v, messageDeleteEvent) + return s.handleActionMessage(w, r, v, model.MessageDeleteEvent) } func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error { - return s.handleActionMessage(w, r, v, messageClearEvent) + return s.handleActionMessage(w, r, v, model.MessageClearEvent) } func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error { @@ -1001,7 +1001,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * return e.With(t) } // Create an action message with the given event type - m := newActionMessage(event, t.ID, sequenceID) + m := model.NewActionMessage(event, t.ID, sequenceID) m.Sender = v.IP() m.User = v.MaybeUserID() m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() @@ -1017,7 +1017,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * if s.config.WebPushPublicKey != "" { go s.publishToWebPushEndpoints(v, m) } - if event == messageDeleteEvent { + if event == model.MessageDeleteEvent { // Delete any existing scheduled message with the same sequence ID deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID) if err != nil { @@ -1246,7 +1246,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo // 7. curl -T file.txt ntfy.sh/mytopic // In all other cases, mostly if file.txt is > message limit, treat it as an attachment func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error { - if m.Event == pollRequestEvent { // Case 1 + if m.Event == model.PollRequestEvent { // Case 1 return s.handleBodyDiscard(body) } else if unifiedpush { return s.handleBodyAsMessageAutoDetect(m, body) // Case 2 @@ -1466,7 +1466,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil { return "", err } - if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent { + if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent { return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this! } return fmt.Sprintf("data: %s\n", buf.String()), nil @@ -1476,7 +1476,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *model.Message) (string, error) { - if msg.Event == messageEvent { // only handle default events + if msg.Event == model.MessageEvent { // only handle default events return strings.ReplaceAll(msg.Message, "\n", " ") + "\n", nil } return "\n", nil // "keepalive" and "open" events just send an empty line @@ -1554,7 +1554,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * topics[i].Unsubscribe(subscriberID) // Order! } }() - if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message + if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message return err } if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { @@ -1577,7 +1577,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * for _, t := range topics { t.Keepalive() } - if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message + if err := sub(v, model.NewKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message return err } } @@ -1703,7 +1703,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi topics[i].Unsubscribe(subscriberID) // Order! } }() - if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message + if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message return err } if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { @@ -1834,26 +1834,26 @@ func parseSince(r *http.Request, poll bool) (model.SinceMarker, error) { // Easy cases (empty, all, none) if since == "" { if poll { - return sinceAllMessages, nil + return model.SinceAllMessages, nil } - return sinceNoMessages, nil + return model.SinceNoMessages, nil } else if since == "all" { - return sinceAllMessages, nil + return model.SinceAllMessages, nil } else if since == "latest" { - return sinceLatestMessage, nil + return model.SinceLatestMessage, nil } else if since == "none" { - return sinceNoMessages, nil + return model.SinceNoMessages, nil } // ID, timestamp, duration - if validMessageID(since) { - return newSinceID(since), nil + if model.ValidMessageID(since) { + return model.NewSinceID(since), nil } else if s, err := strconv.ParseInt(since, 10, 64); err == nil { - return newSinceTime(s), nil + return model.NewSinceTime(s), nil } else if d, err := time.ParseDuration(since); err == nil { - return newSinceTime(time.Now().Add(-1 * d).Unix()), nil + return model.NewSinceTime(time.Now().Add(-1 * d).Unix()), nil } - return sinceNoMessages, errHTTPBadRequestSinceInvalid + return model.SinceNoMessages, errHTTPBadRequestSinceInvalid } func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error { @@ -2009,14 +2009,14 @@ func (s *Server) runFirebaseKeepaliver() { for { select { case <-time.After(s.config.FirebaseKeepaliveInterval): - s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic)) + 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. Given that it's not really necessary to poll, turning it off for now should not have any impact. case <-time.After(s.config.FirebasePollInterval): - s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic)) + s.sendToFirebase(v, model.NewKeepaliveMessage(firebasePollTopic)) */ case <-s.closeChan: return diff --git a/server/server_account.go b/server/server_account.go index 6efb2f43..19a14042 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" "net/http" @@ -641,7 +642,7 @@ func (s *Server) publishSyncEvent(v *visitor) error { if err != nil { return err } - m := newDefaultMessage(syncTopic.ID, string(messageBytes)) + m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes)) if err := syncTopic.Publish(v, m); err != nil { return err } diff --git a/server/server_account_test.go b/server/server_account_test.go index 3e2f94c9..7bf6f6d5 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" "io" @@ -715,12 +716,12 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) // Pre-verify message count and file - ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) + ms, err := s.messageCache.Messages("mytopic1", model.SinceAllMessages, false) require.Nil(t, err) require.Equal(t, 1, len(ms)) require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) - ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) + ms, err = s.messageCache.Messages("mytopic2", model.SinceAllMessages, false) require.Nil(t, err) require.Equal(t, 1, len(ms)) require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) @@ -741,17 +742,17 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { // Verify that messages and attachments were deleted // This does not explicitly call the manager! waitFor(t, func() bool { - ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) + ms, err := s.messageCache.Messages("mytopic1", model.SinceAllMessages, false) require.Nil(t, err) return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID)) }) - ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false) + ms, err = s.messageCache.Messages("mytopic1", model.SinceAllMessages, false) require.Nil(t, err) require.Equal(t, 0, len(ms)) require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) - ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) + ms, err = s.messageCache.Messages("mytopic2", model.SinceAllMessages, false) require.Nil(t, err) require.Equal(t, 1, len(ms)) require.Equal(t, m2.ID, ms[0].ID) diff --git a/server/server_firebase.go b/server/server_firebase.go index ad206e13..9e380f0f 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -126,7 +126,7 @@ func toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format var apnsConfig *messaging.APNSConfig switch m.Event { - case keepaliveEvent, openEvent: + case model.KeepaliveEvent, model.OpenEvent: data = map[string]string{ "id": m.ID, "time": fmt.Sprintf("%d", m.Time), @@ -134,7 +134,7 @@ func toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message "topic": m.Topic, } apnsConfig = createAPNSBackgroundConfig(data) - case pollRequestEvent: + case model.PollRequestEvent: data = map[string]string{ "id": m.ID, "time": fmt.Sprintf("%d", m.Time), @@ -144,7 +144,7 @@ func toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message "poll_id": m.PollID, } apnsConfig = createAPNSAlertConfig(m, data) - case messageDeleteEvent, messageClearEvent: + case model.MessageDeleteEvent, model.MessageClearEvent: data = map[string]string{ "id": m.ID, "time": fmt.Sprintf("%d", m.Time), @@ -153,7 +153,7 @@ func toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message "sequence_id": m.SequenceID, } apnsConfig = createAPNSBackgroundConfig(data) - case messageEvent: + case model.MessageEvent: if auther != nil { // If "anonymous read" for a topic is not allowed, we cannot send the message along // via Firebase. Instead, we send a "poll_request" message, asking the client to poll. diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index a4c8e0b8..c63de657 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -64,7 +64,7 @@ func (s *testFirebaseSender) Messages() []*messaging.Message { } func TestToFirebaseMessage_Keepalive(t *testing.T) { - m := newKeepaliveMessage("mytopic") + m := model.NewKeepaliveMessage("mytopic") fbm, err := toFirebaseMessage(m, nil) require.Nil(t, err) require.Equal(t, "mytopic", fbm.Topic) @@ -95,7 +95,7 @@ func TestToFirebaseMessage_Keepalive(t *testing.T) { } func TestToFirebaseMessage_Open(t *testing.T) { - m := newOpenMessage("mytopic") + m := model.NewOpenMessage("mytopic") fbm, err := toFirebaseMessage(m, nil) require.Nil(t, err) require.Equal(t, "mytopic", fbm.Topic) @@ -126,7 +126,7 @@ func TestToFirebaseMessage_Open(t *testing.T) { } func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { - m := newDefaultMessage("mytopic", "this is a message") + m := model.NewDefaultMessage("mytopic", "this is a message") m.Priority = 4 m.Tags = []string{"tag 1", "tag2"} m.Click = "https://google.com" @@ -220,7 +220,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { } func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) { - m := newDefaultMessage("mytopic", "this is a message") + m := model.NewDefaultMessage("mytopic", "this is a message") m.Priority = 5 fbm, err := toFirebaseMessage(m, &testAuther{Allow: false}) // Not allowed! require.Nil(t, err) diff --git a/server/server_manager_test.go b/server/server_manager_test.go index 02454698..7a1224b8 100644 --- a/server/server_manager_test.go +++ b/server/server_manager_test.go @@ -2,6 +2,7 @@ package server import ( "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/model" "testing" ) @@ -25,6 +26,6 @@ func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testi // Actually deleted _, err := s.messageCache.Message(m.ID) - require.Equal(t, errMessageNotFound, err) + require.Equal(t, model.ErrMessageNotFound, err) }) } diff --git a/server/server_payments_test.go b/server/server_payments_test.go index 64c1affc..06523edc 100644 --- a/server/server_payments_test.go +++ b/server/server_payments_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stripe/stripe-go/v74" "golang.org/x/time/rate" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -546,12 +547,12 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( time.Sleep(time.Second) s.execManager() - ms, err := s.messageCache.Messages("atopic", sinceAllMessages, false) + ms, err := s.messageCache.Messages("atopic", model.SinceAllMessages, false) require.Nil(t, err) require.Equal(t, 2, len(ms)) require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID)) - ms, err = s.messageCache.Messages("ztopic", sinceAllMessages, false) + ms, err = s.messageCache.Messages("ztopic", model.SinceAllMessages, false) require.Nil(t, err) require.Equal(t, 0, len(ms)) require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID)) diff --git a/server/server_test.go b/server/server_test.go index 46881593..357f1138 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -163,14 +163,14 @@ func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { messages := toMessages(t, rr.Body.String()) require.Equal(t, 2, len(messages)) - require.Equal(t, openEvent, messages[0].Event) + require.Equal(t, model.OpenEvent, messages[0].Event) require.Equal(t, "mytopic", messages[0].Topic) require.Equal(t, "", messages[0].Message) require.Equal(t, "", messages[0].Title) require.Equal(t, 0, messages[0].Priority) require.Nil(t, messages[0].Tags) - require.Equal(t, keepaliveEvent, messages[1].Event) + require.Equal(t, model.KeepaliveEvent, messages[1].Event) require.Equal(t, "mytopic", messages[1].Topic) require.Equal(t, "", messages[1].Message) require.Equal(t, "", messages[1].Title) @@ -201,9 +201,9 @@ func TestServer_PublishAndSubscribe(t *testing.T) { subscribeCancel() messages := toMessages(t, subscribeRR.Body.String()) require.Equal(t, 3, len(messages)) - require.Equal(t, openEvent, messages[0].Event) + require.Equal(t, model.OpenEvent, messages[0].Event) - require.Equal(t, messageEvent, messages[1].Event) + require.Equal(t, model.MessageEvent, messages[1].Event) require.Equal(t, "mytopic", messages[1].Topic) require.Equal(t, "my first message", messages[1].Message) require.Equal(t, "", messages[1].Title) @@ -212,7 +212,7 @@ func TestServer_PublishAndSubscribe(t *testing.T) { require.True(t, time.Now().Add(12*time.Hour-5*time.Second).Unix() < messages[1].Expires) require.True(t, time.Now().Add(12*time.Hour+5*time.Second).Unix() > messages[1].Expires) - require.Equal(t, messageEvent, messages[2].Event) + require.Equal(t, model.MessageEvent, messages[2].Event) require.Equal(t, "mytopic", messages[2].Topic) require.Equal(t, "my other message", messages[2].Message) require.Equal(t, "This is a title", messages[2].Title) @@ -426,7 +426,7 @@ func TestServer_PublishAt(t *testing.T) { require.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender! var err error - messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) + messages, err = s.messageCache.Messages("mytopic", model.SinceAllMessages, true) require.Nil(t, err) require.Equal(t, 1, len(messages)) require.Equal(t, "a message", messages[0].Message) @@ -465,7 +465,7 @@ func TestServer_PublishAt_FromUser(t *testing.T) { require.Equal(t, "a message", messages[0].Message) var err error - messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) + messages, err = s.messageCache.Messages("mytopic", model.SinceAllMessages, true) require.Nil(t, err) require.Equal(t, 1, len(messages)) require.Equal(t, "a message", messages[0].Message) @@ -607,8 +607,8 @@ func TestServer_PublishWithNopCache(t *testing.T) { subscribeCancel() messages := toMessages(t, subscribeRR.Body.String()) require.Equal(t, 2, len(messages)) - require.Equal(t, openEvent, messages[0].Event) - require.Equal(t, messageEvent, messages[1].Event) + require.Equal(t, model.OpenEvent, messages[0].Event) + require.Equal(t, model.MessageEvent, messages[1].Event) require.Equal(t, "my first message", messages[1].Message) response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) @@ -654,7 +654,7 @@ func TestServer_PublishAndPollSince(t *testing.T) { } func newMessageWithTimestamp(topic, msg string, timestamp int64) *model.Message { - m := newDefaultMessage(topic, msg) + m := model.NewDefaultMessage(topic, msg) m.Time = timestamp return m } @@ -935,10 +935,10 @@ func TestServer_SubscribeWithQueryFilters(t *testing.T) { messages := toMessages(t, subscribeResponse.Body.String()) require.Equal(t, 3, len(messages)) - require.Equal(t, openEvent, messages[0].Event) - require.Equal(t, messageEvent, messages[1].Event) + require.Equal(t, model.OpenEvent, messages[0].Event) + require.Equal(t, model.MessageEvent, messages[1].Event) require.Equal(t, "ZFS scrub failed", messages[1].Message) - require.Equal(t, keepaliveEvent, messages[2].Event) + require.Equal(t, model.KeepaliveEvent, messages[2].Event) }) } @@ -2652,7 +2652,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { topicID := fmt.Sprintf("topic%d", i) _, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array require.Nil(t, err) - messages = append(messages, newDefaultMessage(topicID, "some message")) + messages = append(messages, model.NewDefaultMessage(topicID, "some message")) } require.Nil(t, s.messageCache.AddMessages(messages)) log.Info("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond)) @@ -4398,7 +4398,7 @@ func TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) { // how the panic occurs: the goroutine spawned by topic.Publish calls sub() after the // handler has already returned and Go has cleaned up the response writer. v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("9.9.9.9"), nil) - msg := newDefaultMessage("mytopic", "straggler message") + msg := model.NewDefaultMessage("mytopic", "straggler message") _ = copiedSub(v, msg) require.False(t, rw.wroteAfterClose.Load(), diff --git a/server/smtp_server.go b/server/smtp_server.go index 9e6588e0..3fd92b2b 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -160,7 +160,7 @@ func (s *smtpSession) Data(r io.Reader) error { if len(body) > conf.MessageSizeLimit { body = body[:conf.MessageSizeLimit] } - m := newDefaultMessage(s.topic, body) + m := model.NewDefaultMessage(s.topic, body) subject := strings.TrimSpace(msg.Header.Get("Subject")) if subject != "" { dec := mime.WordDecoder{} diff --git a/server/types.go b/server/types.go index 5e6ba2f3..6dd25a26 100644 --- a/server/types.go +++ b/server/types.go @@ -8,45 +8,9 @@ import ( "heckel.io/ntfy/v2/util" ) -// Event constants -const ( - openEvent = model.OpenEvent - keepaliveEvent = model.KeepaliveEvent - messageEvent = model.MessageEvent - messageDeleteEvent = model.MessageDeleteEvent - messageClearEvent = model.MessageClearEvent - pollRequestEvent = model.PollRequestEvent - messageIDLength = model.MessageIDLength -) - -// SinceMarker aliases -var ( - sinceAllMessages = model.SinceAllMessages - sinceNoMessages = model.SinceNoMessages - sinceLatestMessage = model.SinceLatestMessage -) - -// Error aliases -var ( - errMessageNotFound = model.ErrMessageNotFound -) - -// Constructors and helpers -var ( - newMessage = model.NewMessage - newDefaultMessage = model.NewDefaultMessage - newOpenMessage = model.NewOpenMessage - newKeepaliveMessage = model.NewKeepaliveMessage - newActionMessage = model.NewActionMessage - newAction = model.NewAction - newSinceTime = model.NewSinceTime - newSinceID = model.NewSinceID - validMessageID = model.ValidMessageID -) - // newPollRequestMessage is a convenience method to create a poll request message func newPollRequestMessage(topic, pollID string) *model.Message { - m := newMessage(pollRequestEvent, topic, newMessageBody) + m := model.NewMessage(model.PollRequestEvent, topic, newMessageBody) m.PollID = pollID return m } @@ -106,7 +70,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { } func (q *queryFilter) Pass(msg *model.Message) bool { - if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent { + if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent { return true // filters only apply to messages } else if q.ID != "" && msg.ID != q.ID { return false From b1eb90addc7dc392a1d91f46700ab92d14035f49 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Feb 2026 18:01:36 -0500 Subject: [PATCH 218/548] Move NewPollRequestMessage --- model/model.go | 7 +++++++ server/server.go | 2 +- server/server_firebase.go | 2 +- server/server_firebase_test.go | 2 +- server/types.go | 7 ------- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/model/model.go b/model/model.go index 58e480d7..b0e2d259 100644 --- a/model/model.go +++ b/model/model.go @@ -146,6 +146,13 @@ func NewActionMessage(event, topic, sequenceID string) *Message { return m } +// NewPollRequestMessage is a convenience method to create a poll request message +func NewPollRequestMessage(topic, pollID string) *Message { + m := NewMessage(PollRequestEvent, topic, "New message") + m.PollID = pollID + return m +} + // ValidMessageID returns true if the given string is a valid message ID func ValidMessageID(s string) bool { return util.ValidRandomString(s, MessageIDLength) diff --git a/server/server.go b/server/server.go index 396355e1..1b6c8d10 100644 --- a/server/server.go +++ b/server/server.go @@ -859,7 +859,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess } } if m.PollID != "" { - m = newPollRequestMessage(t.ID, m.PollID) + m = model.NewPollRequestMessage(t.ID, m.PollID) } m.Sender = v.IP() m.User = v.MaybeUserID() diff --git a/server/server_firebase.go b/server/server_firebase.go index 9e380f0f..0f1bb9b1 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -298,7 +298,7 @@ func maybeTruncateAPNSBodyMessage(s string) string { // This empties all the fields that are not needed for a poll request and just sets the required fields, // most importantly, the PollID. func toPollRequest(m *model.Message) *model.Message { - pr := newPollRequestMessage(m.Topic, m.ID) + pr := model.NewPollRequestMessage(m.Topic, m.ID) pr.ID = m.ID pr.Time = m.Time pr.Priority = m.Priority // Keep priority diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index c63de657..40509fb9 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -251,7 +251,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) { } func TestToFirebaseMessage_PollRequest(t *testing.T) { - m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6") + m := model.NewPollRequestMessage("mytopic", "fOv6k1QbCzo6") fbm, err := toFirebaseMessage(m, nil) require.Nil(t, err) require.Equal(t, "mytopic", fbm.Topic) diff --git a/server/types.go b/server/types.go index 6dd25a26..77a3c33e 100644 --- a/server/types.go +++ b/server/types.go @@ -8,13 +8,6 @@ import ( "heckel.io/ntfy/v2/util" ) -// newPollRequestMessage is a convenience method to create a poll request message -func newPollRequestMessage(topic, pollID string) *model.Message { - m := model.NewMessage(model.PollRequestEvent, topic, newMessageBody) - m.PollID = pollID - return m -} - // publishMessage is used as input when publishing as JSON type publishMessage struct { Topic string `json:"topic"` From 459c80ef9bd399481757baaf294cbc384365d2b5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Feb 2026 21:02:41 -0500 Subject: [PATCH 219/548] Refactor tests again: forEachBackend --- message/store_postgres_test.go | 93 --- message/store_sqlite_test.go | 167 ---- message/store_test.go | 1410 +++++++++++++++++--------------- user/store_postgres_test.go | 180 ---- user/store_sqlite_test.go | 180 ---- user/store_test.go | 1145 ++++++++++++++------------ webpush/store_postgres_test.go | 63 -- webpush/store_sqlite_test.go | 63 -- webpush/store_test.go | 357 ++++---- 9 files changed, 1562 insertions(+), 2096 deletions(-) delete mode 100644 message/store_postgres_test.go delete mode 100644 user/store_postgres_test.go delete mode 100644 user/store_sqlite_test.go delete mode 100644 webpush/store_postgres_test.go delete mode 100644 webpush/store_sqlite_test.go diff --git a/message/store_postgres_test.go b/message/store_postgres_test.go deleted file mode 100644 index 522620db..00000000 --- a/message/store_postgres_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package message_test - -import ( - "testing" - - dbtest "heckel.io/ntfy/v2/db/test" - "heckel.io/ntfy/v2/message" - - "github.com/stretchr/testify/require" -) - -func newTestPostgresStore(t *testing.T) message.Store { - testDB := dbtest.CreateTestPostgres(t) - store, err := message.NewPostgresStore(testDB, 0, 0) - require.Nil(t, err) - return store -} - -func TestPostgresStore_Messages(t *testing.T) { - testCacheMessages(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MessagesLock(t *testing.T) { - testCacheMessagesLock(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MessagesScheduled(t *testing.T) { - testCacheMessagesScheduled(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_Topics(t *testing.T) { - testCacheTopics(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MessagesTagsPrioAndTitle(t *testing.T) { - testCacheMessagesTagsPrioAndTitle(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MessagesSinceID(t *testing.T) { - testCacheMessagesSinceID(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_Prune(t *testing.T) { - testCachePrune(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_Attachments(t *testing.T) { - testCacheAttachments(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_AttachmentsExpired(t *testing.T) { - testCacheAttachmentsExpired(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_Sender(t *testing.T) { - testSender(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_DeleteScheduledBySequenceID(t *testing.T) { - testDeleteScheduledBySequenceID(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MessageByID(t *testing.T) { - testMessageByID(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MarkPublished(t *testing.T) { - testMarkPublished(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_ExpireMessages(t *testing.T) { - testExpireMessages(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MarkAttachmentsDeleted(t *testing.T) { - testMarkAttachmentsDeleted(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_Stats(t *testing.T) { - testStats(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_AddMessages(t *testing.T) { - testAddMessages(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MessagesDue(t *testing.T) { - testMessagesDue(t, newTestPostgresStore(t)) -} - -func TestPostgresStore_MessageFieldRoundTrip(t *testing.T) { - testMessageFieldRoundTrip(t, newTestPostgresStore(t)) -} diff --git a/message/store_sqlite_test.go b/message/store_sqlite_test.go index aa102044..e018fa28 100644 --- a/message/store_sqlite_test.go +++ b/message/store_sqlite_test.go @@ -13,158 +13,6 @@ import ( "heckel.io/ntfy/v2/model" ) -func TestSqliteStore_Messages(t *testing.T) { - testCacheMessages(t, newSqliteTestStore(t)) -} - -func TestMemStore_Messages(t *testing.T) { - testCacheMessages(t, newMemTestStore(t)) -} - -func TestSqliteStore_MessagesLock(t *testing.T) { - testCacheMessagesLock(t, newSqliteTestStore(t)) -} - -func TestMemStore_MessagesLock(t *testing.T) { - testCacheMessagesLock(t, newMemTestStore(t)) -} - -func TestSqliteStore_MessagesScheduled(t *testing.T) { - testCacheMessagesScheduled(t, newSqliteTestStore(t)) -} - -func TestMemStore_MessagesScheduled(t *testing.T) { - testCacheMessagesScheduled(t, newMemTestStore(t)) -} - -func TestSqliteStore_Topics(t *testing.T) { - testCacheTopics(t, newSqliteTestStore(t)) -} - -func TestMemStore_Topics(t *testing.T) { - testCacheTopics(t, newMemTestStore(t)) -} - -func TestSqliteStore_MessagesTagsPrioAndTitle(t *testing.T) { - testCacheMessagesTagsPrioAndTitle(t, newSqliteTestStore(t)) -} - -func TestMemStore_MessagesTagsPrioAndTitle(t *testing.T) { - testCacheMessagesTagsPrioAndTitle(t, newMemTestStore(t)) -} - -func TestSqliteStore_MessagesSinceID(t *testing.T) { - testCacheMessagesSinceID(t, newSqliteTestStore(t)) -} - -func TestMemStore_MessagesSinceID(t *testing.T) { - testCacheMessagesSinceID(t, newMemTestStore(t)) -} - -func TestSqliteStore_Prune(t *testing.T) { - testCachePrune(t, newSqliteTestStore(t)) -} - -func TestMemStore_Prune(t *testing.T) { - testCachePrune(t, newMemTestStore(t)) -} - -func TestSqliteStore_Attachments(t *testing.T) { - testCacheAttachments(t, newSqliteTestStore(t)) -} - -func TestMemStore_Attachments(t *testing.T) { - testCacheAttachments(t, newMemTestStore(t)) -} - -func TestSqliteStore_AttachmentsExpired(t *testing.T) { - testCacheAttachmentsExpired(t, newSqliteTestStore(t)) -} - -func TestMemStore_AttachmentsExpired(t *testing.T) { - testCacheAttachmentsExpired(t, newMemTestStore(t)) -} - -func TestSqliteStore_Sender(t *testing.T) { - testSender(t, newSqliteTestStore(t)) -} - -func TestMemStore_Sender(t *testing.T) { - testSender(t, newMemTestStore(t)) -} - -func TestSqliteStore_DeleteScheduledBySequenceID(t *testing.T) { - testDeleteScheduledBySequenceID(t, newSqliteTestStore(t)) -} - -func TestMemStore_DeleteScheduledBySequenceID(t *testing.T) { - testDeleteScheduledBySequenceID(t, newMemTestStore(t)) -} - -func TestSqliteStore_MessageByID(t *testing.T) { - testMessageByID(t, newSqliteTestStore(t)) -} - -func TestMemStore_MessageByID(t *testing.T) { - testMessageByID(t, newMemTestStore(t)) -} - -func TestSqliteStore_MarkPublished(t *testing.T) { - testMarkPublished(t, newSqliteTestStore(t)) -} - -func TestMemStore_MarkPublished(t *testing.T) { - testMarkPublished(t, newMemTestStore(t)) -} - -func TestSqliteStore_ExpireMessages(t *testing.T) { - testExpireMessages(t, newSqliteTestStore(t)) -} - -func TestMemStore_ExpireMessages(t *testing.T) { - testExpireMessages(t, newMemTestStore(t)) -} - -func TestSqliteStore_MarkAttachmentsDeleted(t *testing.T) { - testMarkAttachmentsDeleted(t, newSqliteTestStore(t)) -} - -func TestMemStore_MarkAttachmentsDeleted(t *testing.T) { - testMarkAttachmentsDeleted(t, newMemTestStore(t)) -} - -func TestSqliteStore_Stats(t *testing.T) { - testStats(t, newSqliteTestStore(t)) -} - -func TestMemStore_Stats(t *testing.T) { - testStats(t, newMemTestStore(t)) -} - -func TestSqliteStore_AddMessages(t *testing.T) { - testAddMessages(t, newSqliteTestStore(t)) -} - -func TestMemStore_AddMessages(t *testing.T) { - testAddMessages(t, newMemTestStore(t)) -} - -func TestSqliteStore_MessagesDue(t *testing.T) { - testMessagesDue(t, newSqliteTestStore(t)) -} - -func TestMemStore_MessagesDue(t *testing.T) { - testMessagesDue(t, newMemTestStore(t)) -} - -func TestSqliteStore_MessageFieldRoundTrip(t *testing.T) { - testMessageFieldRoundTrip(t, newSqliteTestStore(t)) -} - -func TestMemStore_MessageFieldRoundTrip(t *testing.T) { - testMessageFieldRoundTrip(t, newMemTestStore(t)) -} - func TestSqliteStore_Migration_From0(t *testing.T) { filename := newSqliteTestStoreFile(t) db, err := sql.Open("sqlite3", filename) @@ -419,14 +267,6 @@ func TestNopStore(t *testing.T) { require.Empty(t, topics) } -func newSqliteTestStore(t *testing.T) message.Store { - filename := filepath.Join(t.TempDir(), "cache.db") - s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false) - require.Nil(t, err) - t.Cleanup(func() { s.Close() }) - return s -} - func newSqliteTestStoreFile(t *testing.T) string { return filepath.Join(t.TempDir(), "cache.db") } @@ -438,13 +278,6 @@ func newSqliteTestStoreFromFile(t *testing.T, filename, startupQueries string) m return s } -func newMemTestStore(t *testing.T) message.Store { - s, err := message.NewMemStore() - require.Nil(t, err) - t.Cleanup(func() { s.Close() }) - return s -} - func checkSqliteSchemaVersion(t *testing.T, filename string) { db, err := sql.Open("sqlite3", filename) require.Nil(t, err) diff --git a/message/store_test.go b/message/store_test.go index e3297c96..50bf84f3 100644 --- a/message/store_test.go +++ b/message/store_test.go @@ -2,6 +2,7 @@ package message_test import ( "net/netip" + "path/filepath" "sort" "sync" "testing" @@ -9,759 +10,832 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/model" ) -func testCacheMessages(t *testing.T, s message.Store) { - m1 := model.NewDefaultMessage("mytopic", "my message") - m1.Time = 1 - - m2 := model.NewDefaultMessage("mytopic", "my other message") - m2.Time = 2 - - require.Nil(t, s.AddMessage(m1)) - require.Nil(t, s.AddMessage(model.NewDefaultMessage("example", "my example message"))) - require.Nil(t, s.AddMessage(m2)) - - // Adding invalid - require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewKeepaliveMessage("mytopic"))) // These should not be added! - require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewOpenMessage("example"))) // These should not be added! - - // mytopic: count - counts, err := s.MessageCounts() +func newSqliteTestStore(t *testing.T) message.Store { + filename := filepath.Join(t.TempDir(), "cache.db") + s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false) require.Nil(t, err) - require.Equal(t, 2, counts["mytopic"]) - - // mytopic: since all - messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) - require.Equal(t, 2, len(messages)) - require.Equal(t, "my message", messages[0].Message) - require.Equal(t, "mytopic", messages[0].Topic) - require.Equal(t, model.MessageEvent, messages[0].Event) - require.Equal(t, "", messages[0].Title) - require.Equal(t, 0, messages[0].Priority) - require.Nil(t, messages[0].Tags) - require.Equal(t, "my other message", messages[1].Message) - - // mytopic: since none - messages, _ = s.Messages("mytopic", model.SinceNoMessages, false) - require.Empty(t, messages) - - // mytopic: since m1 (by ID) - messages, _ = s.Messages("mytopic", model.NewSinceID(m1.ID), false) - require.Equal(t, 1, len(messages)) - require.Equal(t, m2.ID, messages[0].ID) - require.Equal(t, "my other message", messages[0].Message) - require.Equal(t, "mytopic", messages[0].Topic) - - // mytopic: since 2 - messages, _ = s.Messages("mytopic", model.NewSinceTime(2), false) - require.Equal(t, 1, len(messages)) - require.Equal(t, "my other message", messages[0].Message) - - // mytopic: latest - messages, _ = s.Messages("mytopic", model.SinceLatestMessage, false) - require.Equal(t, 1, len(messages)) - require.Equal(t, "my other message", messages[0].Message) - - // example: count - counts, err = s.MessageCounts() - require.Nil(t, err) - require.Equal(t, 1, counts["example"]) - - // example: since all - messages, _ = s.Messages("example", model.SinceAllMessages, false) - require.Equal(t, "my example message", messages[0].Message) - - // non-existing: count - counts, err = s.MessageCounts() - require.Nil(t, err) - require.Equal(t, 0, counts["doesnotexist"]) - - // non-existing: since all - messages, _ = s.Messages("doesnotexist", model.SinceAllMessages, false) - require.Empty(t, messages) + t.Cleanup(func() { s.Close() }) + return s } -func testCacheMessagesLock(t *testing.T, s message.Store) { - var wg sync.WaitGroup - for i := 0; i < 5000; i++ { - wg.Add(1) - go func() { - assert.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "test message"))) - wg.Done() - }() - } - wg.Wait() +func newMemTestStore(t *testing.T) message.Store { + s, err := message.NewMemStore() + require.Nil(t, err) + t.Cleanup(func() { s.Close() }) + return s } -func testCacheMessagesScheduled(t *testing.T, s message.Store) { - m1 := model.NewDefaultMessage("mytopic", "message 1") - m2 := model.NewDefaultMessage("mytopic", "message 2") - m2.Time = time.Now().Add(time.Hour).Unix() - m3 := model.NewDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! - m4 := model.NewDefaultMessage("mytopic2", "message 4") - m4.Time = time.Now().Add(time.Minute).Unix() - require.Nil(t, s.AddMessage(m1)) - require.Nil(t, s.AddMessage(m2)) - require.Nil(t, s.AddMessage(m3)) - - messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) // exclude scheduled - require.Equal(t, 1, len(messages)) - require.Equal(t, "message 1", messages[0].Message) - - messages, _ = s.Messages("mytopic", model.SinceAllMessages, true) // include scheduled - require.Equal(t, 3, len(messages)) - require.Equal(t, "message 1", messages[0].Message) - require.Equal(t, "message 3", messages[1].Message) // Order! - require.Equal(t, "message 2", messages[2].Message) - - messages, _ = s.MessagesDue() - require.Empty(t, messages) +func newTestPostgresStore(t *testing.T) message.Store { + testDB := dbtest.CreateTestPostgres(t) + store, err := message.NewPostgresStore(testDB, 0, 0) + require.Nil(t, err) + return store } -func testCacheTopics(t *testing.T, s message.Store) { - require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic1", "my example message"))) - require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 1"))) - require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 2"))) - require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 3"))) - - topics, err := s.Topics() - if err != nil { - t.Fatal(err) - } - require.Equal(t, 2, len(topics)) - require.Contains(t, topics, "topic1") - require.Contains(t, topics, "topic2") +func forEachBackend(t *testing.T, f func(t *testing.T, s message.Store)) { + t.Run("sqlite", func(t *testing.T) { + f(t, newSqliteTestStore(t)) + }) + t.Run("mem", func(t *testing.T) { + f(t, newMemTestStore(t)) + }) + t.Run("postgres", func(t *testing.T) { + f(t, newTestPostgresStore(t)) + }) } -func testCacheMessagesTagsPrioAndTitle(t *testing.T, s message.Store) { - m := model.NewDefaultMessage("mytopic", "some message") - m.Tags = []string{"tag1", "tag2"} - m.Priority = 5 - m.Title = "some title" - require.Nil(t, s.AddMessage(m)) +func TestStore_Messages(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + m1 := model.NewDefaultMessage("mytopic", "my message") + m1.Time = 1 - messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) - require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags) - require.Equal(t, 5, messages[0].Priority) - require.Equal(t, "some title", messages[0].Title) + m2 := model.NewDefaultMessage("mytopic", "my other message") + m2.Time = 2 + + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("example", "my example message"))) + require.Nil(t, s.AddMessage(m2)) + + // Adding invalid + require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewKeepaliveMessage("mytopic"))) // These should not be added! + require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewOpenMessage("example"))) // These should not be added! + + // mytopic: count + counts, err := s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 2, counts["mytopic"]) + + // mytopic: since all + messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) + require.Equal(t, 2, len(messages)) + require.Equal(t, "my message", messages[0].Message) + require.Equal(t, "mytopic", messages[0].Topic) + require.Equal(t, model.MessageEvent, messages[0].Event) + require.Equal(t, "", messages[0].Title) + require.Equal(t, 0, messages[0].Priority) + require.Nil(t, messages[0].Tags) + require.Equal(t, "my other message", messages[1].Message) + + // mytopic: since none + messages, _ = s.Messages("mytopic", model.SinceNoMessages, false) + require.Empty(t, messages) + + // mytopic: since m1 (by ID) + messages, _ = s.Messages("mytopic", model.NewSinceID(m1.ID), false) + require.Equal(t, 1, len(messages)) + require.Equal(t, m2.ID, messages[0].ID) + require.Equal(t, "my other message", messages[0].Message) + require.Equal(t, "mytopic", messages[0].Topic) + + // mytopic: since 2 + messages, _ = s.Messages("mytopic", model.NewSinceTime(2), false) + require.Equal(t, 1, len(messages)) + require.Equal(t, "my other message", messages[0].Message) + + // mytopic: latest + messages, _ = s.Messages("mytopic", model.SinceLatestMessage, false) + require.Equal(t, 1, len(messages)) + require.Equal(t, "my other message", messages[0].Message) + + // example: count + counts, err = s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 1, counts["example"]) + + // example: since all + messages, _ = s.Messages("example", model.SinceAllMessages, false) + require.Equal(t, "my example message", messages[0].Message) + + // non-existing: count + counts, err = s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 0, counts["doesnotexist"]) + + // non-existing: since all + messages, _ = s.Messages("doesnotexist", model.SinceAllMessages, false) + require.Empty(t, messages) + }) } -func testCacheMessagesSinceID(t *testing.T, s message.Store) { - m1 := model.NewDefaultMessage("mytopic", "message 1") - m1.Time = 100 - m2 := model.NewDefaultMessage("mytopic", "message 2") - m2.Time = 200 - m3 := model.NewDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 - m4 := model.NewDefaultMessage("mytopic", "message 4") - m4.Time = 400 - m5 := model.NewDefaultMessage("mytopic", "message 5") - m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 - m6 := model.NewDefaultMessage("mytopic", "message 6") - m6.Time = 600 - m7 := model.NewDefaultMessage("mytopic", "message 7") - m7.Time = 700 - - require.Nil(t, s.AddMessage(m1)) - require.Nil(t, s.AddMessage(m2)) - require.Nil(t, s.AddMessage(m3)) - require.Nil(t, s.AddMessage(m4)) - require.Nil(t, s.AddMessage(m5)) - require.Nil(t, s.AddMessage(m6)) - require.Nil(t, s.AddMessage(m7)) - - // Case 1: Since ID exists, exclude scheduled - messages, _ := s.Messages("mytopic", model.NewSinceID(m2.ID), false) - require.Equal(t, 3, len(messages)) - require.Equal(t, "message 4", messages[0].Message) - require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5! - require.Equal(t, "message 7", messages[2].Message) - - // Case 2: Since ID exists, include scheduled - messages, _ = s.Messages("mytopic", model.NewSinceID(m2.ID), true) - require.Equal(t, 5, len(messages)) - require.Equal(t, "message 4", messages[0].Message) - require.Equal(t, "message 6", messages[1].Message) - require.Equal(t, "message 7", messages[2].Message) - require.Equal(t, "message 5", messages[3].Message) // Order! - require.Equal(t, "message 3", messages[4].Message) // Order! - - // Case 3: Since ID does not exist (-> Return all messages), include scheduled - messages, _ = s.Messages("mytopic", model.NewSinceID("doesntexist"), true) - require.Equal(t, 7, len(messages)) - require.Equal(t, "message 1", messages[0].Message) - require.Equal(t, "message 2", messages[1].Message) - require.Equal(t, "message 4", messages[2].Message) - require.Equal(t, "message 6", messages[3].Message) - require.Equal(t, "message 7", messages[4].Message) - require.Equal(t, "message 5", messages[5].Message) // Order! - require.Equal(t, "message 3", messages[6].Message) // Order! - - // Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled - messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), false) - require.Equal(t, 0, len(messages)) - - // Case 5: Since ID exists and is last message (-> Return no messages), include scheduled - messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), true) - require.Equal(t, 2, len(messages)) - require.Equal(t, "message 5", messages[0].Message) - require.Equal(t, "message 3", messages[1].Message) +func TestStore_MessagesLock(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + var wg sync.WaitGroup + for i := 0; i < 5000; i++ { + wg.Add(1) + go func() { + assert.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "test message"))) + wg.Done() + }() + } + wg.Wait() + }) } -func testCachePrune(t *testing.T, s message.Store) { - now := time.Now().Unix() +func TestStore_MessagesScheduled(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + m1 := model.NewDefaultMessage("mytopic", "message 1") + m2 := model.NewDefaultMessage("mytopic", "message 2") + m2.Time = time.Now().Add(time.Hour).Unix() + m3 := model.NewDefaultMessage("mytopic", "message 3") + m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! + m4 := model.NewDefaultMessage("mytopic2", "message 4") + m4.Time = time.Now().Add(time.Minute).Unix() + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m3)) - m1 := model.NewDefaultMessage("mytopic", "my message") - m1.Time = now - 10 - m1.Expires = now - 5 + messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) // exclude scheduled + require.Equal(t, 1, len(messages)) + require.Equal(t, "message 1", messages[0].Message) - m2 := model.NewDefaultMessage("mytopic", "my other message") - m2.Time = now - 5 - m2.Expires = now + 5 // In the future + messages, _ = s.Messages("mytopic", model.SinceAllMessages, true) // include scheduled + require.Equal(t, 3, len(messages)) + require.Equal(t, "message 1", messages[0].Message) + require.Equal(t, "message 3", messages[1].Message) // Order! + require.Equal(t, "message 2", messages[2].Message) - m3 := model.NewDefaultMessage("another_topic", "and another one") - m3.Time = now - 12 - m3.Expires = now - 2 - - require.Nil(t, s.AddMessage(m1)) - require.Nil(t, s.AddMessage(m2)) - require.Nil(t, s.AddMessage(m3)) - - counts, err := s.MessageCounts() - require.Nil(t, err) - require.Equal(t, 2, counts["mytopic"]) - require.Equal(t, 1, counts["another_topic"]) - - expiredMessageIDs, err := s.MessagesExpired() - require.Nil(t, err) - require.Nil(t, s.DeleteMessages(expiredMessageIDs...)) - - counts, err = s.MessageCounts() - require.Nil(t, err) - require.Equal(t, 1, counts["mytopic"]) - require.Equal(t, 0, counts["another_topic"]) - - messages, err := s.Messages("mytopic", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "my other message", messages[0].Message) + messages, _ = s.MessagesDue() + require.Empty(t, messages) + }) } -func testCacheAttachments(t *testing.T, s message.Store) { - expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired - m := model.NewDefaultMessage("mytopic", "flower for you") - m.ID = "m1" - m.SequenceID = "m1" - m.Sender = netip.MustParseAddr("1.2.3.4") - m.Attachment = &model.Attachment{ - Name: "flower.jpg", - Type: "image/jpeg", - Size: 5000, - Expires: expires1, - URL: "https://ntfy.sh/file/AbDeFgJhal.jpg", - } - require.Nil(t, s.AddMessage(m)) +func TestStore_Topics(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic1", "my example message"))) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 1"))) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 2"))) + require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 3"))) - expires2 := time.Now().Add(2 * time.Hour).Unix() // Future - m = model.NewDefaultMessage("mytopic", "sending you a car") - m.ID = "m2" - m.SequenceID = "m2" - m.Sender = netip.MustParseAddr("1.2.3.4") - m.Attachment = &model.Attachment{ - Name: "car.jpg", - Type: "image/jpeg", - Size: 10000, - Expires: expires2, - URL: "https://ntfy.sh/file/aCaRURL.jpg", - } - require.Nil(t, s.AddMessage(m)) - - expires3 := time.Now().Add(1 * time.Hour).Unix() // Future - m = model.NewDefaultMessage("another-topic", "sending you another car") - m.ID = "m3" - m.SequenceID = "m3" - m.User = "u_BAsbaAa" - m.Sender = netip.MustParseAddr("5.6.7.8") - m.Attachment = &model.Attachment{ - Name: "another-car.jpg", - Type: "image/jpeg", - Size: 20000, - Expires: expires3, - URL: "https://ntfy.sh/file/zakaDHFW.jpg", - } - require.Nil(t, s.AddMessage(m)) - - messages, err := s.Messages("mytopic", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) - - require.Equal(t, "flower for you", messages[0].Message) - require.Equal(t, "flower.jpg", messages[0].Attachment.Name) - require.Equal(t, "image/jpeg", messages[0].Attachment.Type) - require.Equal(t, int64(5000), messages[0].Attachment.Size) - require.Equal(t, expires1, messages[0].Attachment.Expires) - require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) - require.Equal(t, "1.2.3.4", messages[0].Sender.String()) - - require.Equal(t, "sending you a car", messages[1].Message) - require.Equal(t, "car.jpg", messages[1].Attachment.Name) - require.Equal(t, "image/jpeg", messages[1].Attachment.Type) - require.Equal(t, int64(10000), messages[1].Attachment.Size) - require.Equal(t, expires2, messages[1].Attachment.Expires) - require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) - require.Equal(t, "1.2.3.4", messages[1].Sender.String()) - - size, err := s.AttachmentBytesUsedBySender("1.2.3.4") - require.Nil(t, err) - require.Equal(t, int64(10000), size) - - size, err = s.AttachmentBytesUsedBySender("5.6.7.8") - require.Nil(t, err) - require.Equal(t, int64(0), size) // Accounted to the user, not the IP! - - size, err = s.AttachmentBytesUsedByUser("u_BAsbaAa") - require.Nil(t, err) - require.Equal(t, int64(20000), size) + topics, err := s.Topics() + if err != nil { + t.Fatal(err) + } + require.Equal(t, 2, len(topics)) + require.Contains(t, topics, "topic1") + require.Contains(t, topics, "topic2") + }) } -func testCacheAttachmentsExpired(t *testing.T, s message.Store) { - m := model.NewDefaultMessage("mytopic", "flower for you") - m.ID = "m1" - m.SequenceID = "m1" - m.Expires = time.Now().Add(time.Hour).Unix() - require.Nil(t, s.AddMessage(m)) +func TestStore_MessagesTagsPrioAndTitle(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + m := model.NewDefaultMessage("mytopic", "some message") + m.Tags = []string{"tag1", "tag2"} + m.Priority = 5 + m.Title = "some title" + require.Nil(t, s.AddMessage(m)) - m = model.NewDefaultMessage("mytopic", "message with attachment") - m.ID = "m2" - m.SequenceID = "m2" - m.Expires = time.Now().Add(2 * time.Hour).Unix() - m.Attachment = &model.Attachment{ - Name: "car.jpg", - Type: "image/jpeg", - Size: 10000, - Expires: time.Now().Add(2 * time.Hour).Unix(), - URL: "https://ntfy.sh/file/aCaRURL.jpg", - } - require.Nil(t, s.AddMessage(m)) - - m = model.NewDefaultMessage("mytopic", "message with external attachment") - m.ID = "m3" - m.SequenceID = "m3" - m.Expires = time.Now().Add(2 * time.Hour).Unix() - m.Attachment = &model.Attachment{ - Name: "car.jpg", - Type: "image/jpeg", - Expires: 0, // Unknown! - URL: "https://somedomain.com/car.jpg", - } - require.Nil(t, s.AddMessage(m)) - - m = model.NewDefaultMessage("mytopic2", "message with expired attachment") - m.ID = "m4" - m.SequenceID = "m4" - m.Expires = time.Now().Add(2 * time.Hour).Unix() - m.Attachment = &model.Attachment{ - Name: "expired-car.jpg", - Type: "image/jpeg", - Size: 20000, - Expires: time.Now().Add(-1 * time.Hour).Unix(), - URL: "https://ntfy.sh/file/aCaRURL.jpg", - } - require.Nil(t, s.AddMessage(m)) - - ids, err := s.AttachmentsExpired() - require.Nil(t, err) - require.Equal(t, 1, len(ids)) - require.Equal(t, "m4", ids[0]) + messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) + require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags) + require.Equal(t, 5, messages[0].Priority) + require.Equal(t, "some title", messages[0].Title) + }) } -func testSender(t *testing.T, s message.Store) { - m1 := model.NewDefaultMessage("mytopic", "mymessage") - m1.Sender = netip.MustParseAddr("1.2.3.4") - require.Nil(t, s.AddMessage(m1)) +func TestStore_MessagesSinceID(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + m1 := model.NewDefaultMessage("mytopic", "message 1") + m1.Time = 100 + m2 := model.NewDefaultMessage("mytopic", "message 2") + m2.Time = 200 + m3 := model.NewDefaultMessage("mytopic", "message 3") + m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 + m4 := model.NewDefaultMessage("mytopic", "message 4") + m4.Time = 400 + m5 := model.NewDefaultMessage("mytopic", "message 5") + m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 + m6 := model.NewDefaultMessage("mytopic", "message 6") + m6.Time = 600 + m7 := model.NewDefaultMessage("mytopic", "message 7") + m7.Time = 700 - m2 := model.NewDefaultMessage("mytopic", "mymessage without sender") - require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m3)) + require.Nil(t, s.AddMessage(m4)) + require.Nil(t, s.AddMessage(m5)) + require.Nil(t, s.AddMessage(m6)) + require.Nil(t, s.AddMessage(m7)) - messages, err := s.Messages("mytopic", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) - require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4")) - require.Equal(t, messages[1].Sender, netip.Addr{}) + // Case 1: Since ID exists, exclude scheduled + messages, _ := s.Messages("mytopic", model.NewSinceID(m2.ID), false) + require.Equal(t, 3, len(messages)) + require.Equal(t, "message 4", messages[0].Message) + require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5! + require.Equal(t, "message 7", messages[2].Message) + + // Case 2: Since ID exists, include scheduled + messages, _ = s.Messages("mytopic", model.NewSinceID(m2.ID), true) + require.Equal(t, 5, len(messages)) + require.Equal(t, "message 4", messages[0].Message) + require.Equal(t, "message 6", messages[1].Message) + require.Equal(t, "message 7", messages[2].Message) + require.Equal(t, "message 5", messages[3].Message) // Order! + require.Equal(t, "message 3", messages[4].Message) // Order! + + // Case 3: Since ID does not exist (-> Return all messages), include scheduled + messages, _ = s.Messages("mytopic", model.NewSinceID("doesntexist"), true) + require.Equal(t, 7, len(messages)) + require.Equal(t, "message 1", messages[0].Message) + require.Equal(t, "message 2", messages[1].Message) + require.Equal(t, "message 4", messages[2].Message) + require.Equal(t, "message 6", messages[3].Message) + require.Equal(t, "message 7", messages[4].Message) + require.Equal(t, "message 5", messages[5].Message) // Order! + require.Equal(t, "message 3", messages[6].Message) // Order! + + // Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled + messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), false) + require.Equal(t, 0, len(messages)) + + // Case 5: Since ID exists and is last message (-> Return no messages), include scheduled + messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), true) + require.Equal(t, 2, len(messages)) + require.Equal(t, "message 5", messages[0].Message) + require.Equal(t, "message 3", messages[1].Message) + }) } -func testDeleteScheduledBySequenceID(t *testing.T, s message.Store) { - // Create a scheduled (unpublished) message - scheduledMsg := model.NewDefaultMessage("mytopic", "scheduled message") - scheduledMsg.ID = "scheduled1" - scheduledMsg.SequenceID = "seq123" - scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled - require.Nil(t, s.AddMessage(scheduledMsg)) +func TestStore_Prune(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + now := time.Now().Unix() - // Create a published message with different sequence ID - publishedMsg := model.NewDefaultMessage("mytopic", "published message") - publishedMsg.ID = "published1" - publishedMsg.SequenceID = "seq456" - publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published - require.Nil(t, s.AddMessage(publishedMsg)) + m1 := model.NewDefaultMessage("mytopic", "my message") + m1.Time = now - 10 + m1.Expires = now - 5 - // Create a scheduled message in a different topic - otherTopicMsg := model.NewDefaultMessage("othertopic", "other scheduled") - otherTopicMsg.ID = "other1" - otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg - otherTopicMsg.Time = time.Now().Add(time.Hour).Unix() - require.Nil(t, s.AddMessage(otherTopicMsg)) + m2 := model.NewDefaultMessage("mytopic", "my other message") + m2.Time = now - 5 + m2.Expires = now + 5 // In the future - // Verify all messages exist (including scheduled) - messages, err := s.Messages("mytopic", model.SinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) + m3 := model.NewDefaultMessage("another_topic", "and another one") + m3.Time = now - 12 + m3.Expires = now - 2 - messages, err = s.Messages("othertopic", model.SinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m3)) - // Delete scheduled message by sequence ID and verify returned IDs - deletedIDs, err := s.DeleteScheduledBySequenceID("mytopic", "seq123") - require.Nil(t, err) - require.Equal(t, 1, len(deletedIDs)) - require.Equal(t, "scheduled1", deletedIDs[0]) + counts, err := s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 2, counts["mytopic"]) + require.Equal(t, 1, counts["another_topic"]) - // Verify scheduled message is deleted - messages, err = s.Messages("mytopic", model.SinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "published message", messages[0].Message) + expiredMessageIDs, err := s.MessagesExpired() + require.Nil(t, err) + require.Nil(t, s.DeleteMessages(expiredMessageIDs...)) - // Verify other topic's message still exists (topic-scoped deletion) - messages, err = s.Messages("othertopic", model.SinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "other scheduled", messages[0].Message) + counts, err = s.MessageCounts() + require.Nil(t, err) + require.Equal(t, 1, counts["mytopic"]) + require.Equal(t, 0, counts["another_topic"]) - // Deleting non-existent sequence ID should return empty list - deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "nonexistent") - require.Nil(t, err) - require.Empty(t, deletedIDs) - - // Deleting published message should not affect it (only deletes unpublished) - deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "seq456") - require.Nil(t, err) - require.Empty(t, deletedIDs) - - messages, err = s.Messages("mytopic", model.SinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "published message", messages[0].Message) + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "my other message", messages[0].Message) + }) } -func testMessageByID(t *testing.T, s message.Store) { - // Add a message - m := model.NewDefaultMessage("mytopic", "some message") - m.Title = "some title" - m.Priority = 4 - m.Tags = []string{"tag1", "tag2"} - require.Nil(t, s.AddMessage(m)) +func TestStore_Attachments(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired + m := model.NewDefaultMessage("mytopic", "flower for you") + m.ID = "m1" + m.SequenceID = "m1" + m.Sender = netip.MustParseAddr("1.2.3.4") + m.Attachment = &model.Attachment{ + Name: "flower.jpg", + Type: "image/jpeg", + Size: 5000, + Expires: expires1, + URL: "https://ntfy.sh/file/AbDeFgJhal.jpg", + } + require.Nil(t, s.AddMessage(m)) - // Retrieve by ID - retrieved, err := s.Message(m.ID) - require.Nil(t, err) - require.Equal(t, m.ID, retrieved.ID) - require.Equal(t, "mytopic", retrieved.Topic) - require.Equal(t, "some message", retrieved.Message) - require.Equal(t, "some title", retrieved.Title) - require.Equal(t, 4, retrieved.Priority) - require.Equal(t, []string{"tag1", "tag2"}, retrieved.Tags) + expires2 := time.Now().Add(2 * time.Hour).Unix() // Future + m = model.NewDefaultMessage("mytopic", "sending you a car") + m.ID = "m2" + m.SequenceID = "m2" + m.Sender = netip.MustParseAddr("1.2.3.4") + m.Attachment = &model.Attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Size: 10000, + Expires: expires2, + URL: "https://ntfy.sh/file/aCaRURL.jpg", + } + require.Nil(t, s.AddMessage(m)) - // Non-existent ID returns ErrMessageNotFound - _, err = s.Message("doesnotexist") - require.Equal(t, model.ErrMessageNotFound, err) + expires3 := time.Now().Add(1 * time.Hour).Unix() // Future + m = model.NewDefaultMessage("another-topic", "sending you another car") + m.ID = "m3" + m.SequenceID = "m3" + m.User = "u_BAsbaAa" + m.Sender = netip.MustParseAddr("5.6.7.8") + m.Attachment = &model.Attachment{ + Name: "another-car.jpg", + Type: "image/jpeg", + Size: 20000, + Expires: expires3, + URL: "https://ntfy.sh/file/zakaDHFW.jpg", + } + require.Nil(t, s.AddMessage(m)) + + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + + require.Equal(t, "flower for you", messages[0].Message) + require.Equal(t, "flower.jpg", messages[0].Attachment.Name) + require.Equal(t, "image/jpeg", messages[0].Attachment.Type) + require.Equal(t, int64(5000), messages[0].Attachment.Size) + require.Equal(t, expires1, messages[0].Attachment.Expires) + require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) + require.Equal(t, "1.2.3.4", messages[0].Sender.String()) + + require.Equal(t, "sending you a car", messages[1].Message) + require.Equal(t, "car.jpg", messages[1].Attachment.Name) + require.Equal(t, "image/jpeg", messages[1].Attachment.Type) + require.Equal(t, int64(10000), messages[1].Attachment.Size) + require.Equal(t, expires2, messages[1].Attachment.Expires) + require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) + require.Equal(t, "1.2.3.4", messages[1].Sender.String()) + + size, err := s.AttachmentBytesUsedBySender("1.2.3.4") + require.Nil(t, err) + require.Equal(t, int64(10000), size) + + size, err = s.AttachmentBytesUsedBySender("5.6.7.8") + require.Nil(t, err) + require.Equal(t, int64(0), size) // Accounted to the user, not the IP! + + size, err = s.AttachmentBytesUsedByUser("u_BAsbaAa") + require.Nil(t, err) + require.Equal(t, int64(20000), size) + }) } -func testMarkPublished(t *testing.T, s message.Store) { - // Add a scheduled message (future time → unpublished) - m := model.NewDefaultMessage("mytopic", "scheduled message") - m.Time = time.Now().Add(time.Hour).Unix() - require.Nil(t, s.AddMessage(m)) +func TestStore_AttachmentsExpired(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + m := model.NewDefaultMessage("mytopic", "flower for you") + m.ID = "m1" + m.SequenceID = "m1" + m.Expires = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m)) - // Verify it does not appear in non-scheduled queries - messages, err := s.Messages("mytopic", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 0, len(messages)) + m = model.NewDefaultMessage("mytopic", "message with attachment") + m.ID = "m2" + m.SequenceID = "m2" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &model.Attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Size: 10000, + Expires: time.Now().Add(2 * time.Hour).Unix(), + URL: "https://ntfy.sh/file/aCaRURL.jpg", + } + require.Nil(t, s.AddMessage(m)) - // Verify it does appear in scheduled queries - messages, err = s.Messages("mytopic", model.SinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) + m = model.NewDefaultMessage("mytopic", "message with external attachment") + m.ID = "m3" + m.SequenceID = "m3" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &model.Attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Expires: 0, // Unknown! + URL: "https://somedomain.com/car.jpg", + } + require.Nil(t, s.AddMessage(m)) - // Mark as published - require.Nil(t, s.MarkPublished(m)) + m = model.NewDefaultMessage("mytopic2", "message with expired attachment") + m.ID = "m4" + m.SequenceID = "m4" + m.Expires = time.Now().Add(2 * time.Hour).Unix() + m.Attachment = &model.Attachment{ + Name: "expired-car.jpg", + Type: "image/jpeg", + Size: 20000, + Expires: time.Now().Add(-1 * time.Hour).Unix(), + URL: "https://ntfy.sh/file/aCaRURL.jpg", + } + require.Nil(t, s.AddMessage(m)) - // Now it should appear in non-scheduled queries too - messages, err = s.Messages("mytopic", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "scheduled message", messages[0].Message) + ids, err := s.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, 1, len(ids)) + require.Equal(t, "m4", ids[0]) + }) } -func testExpireMessages(t *testing.T, s message.Store) { - // Add messages to two topics - m1 := model.NewDefaultMessage("topic1", "message 1") - m1.Expires = time.Now().Add(time.Hour).Unix() - m2 := model.NewDefaultMessage("topic1", "message 2") - m2.Expires = time.Now().Add(time.Hour).Unix() - m3 := model.NewDefaultMessage("topic2", "message 3") - m3.Expires = time.Now().Add(time.Hour).Unix() - require.Nil(t, s.AddMessage(m1)) - require.Nil(t, s.AddMessage(m2)) - require.Nil(t, s.AddMessage(m3)) +func TestStore_Sender(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + m1 := model.NewDefaultMessage("mytopic", "mymessage") + m1.Sender = netip.MustParseAddr("1.2.3.4") + require.Nil(t, s.AddMessage(m1)) - // Verify all messages exist - messages, err := s.Messages("topic1", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) - messages, err = s.Messages("topic2", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) + m2 := model.NewDefaultMessage("mytopic", "mymessage without sender") + require.Nil(t, s.AddMessage(m2)) - // Expire topic1 messages - require.Nil(t, s.ExpireMessages("topic1")) - - // topic1 messages should now be expired (expires set to past) - expiredIDs, err := s.MessagesExpired() - require.Nil(t, err) - require.Equal(t, 2, len(expiredIDs)) - sort.Strings(expiredIDs) - expectedIDs := []string{m1.ID, m2.ID} - sort.Strings(expectedIDs) - require.Equal(t, expectedIDs, expiredIDs) - - // topic2 should be unaffected - messages, err = s.Messages("topic2", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "message 3", messages[0].Message) + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4")) + require.Equal(t, messages[1].Sender, netip.Addr{}) + }) } -func testMarkAttachmentsDeleted(t *testing.T, s message.Store) { - // Add a message with an expired attachment (file needs cleanup) - m1 := model.NewDefaultMessage("mytopic", "old file") - m1.ID = "msg1" - m1.SequenceID = "msg1" - m1.Expires = time.Now().Add(time.Hour).Unix() - m1.Attachment = &model.Attachment{ - Name: "old.pdf", - Type: "application/pdf", - Size: 50000, - Expires: time.Now().Add(-time.Hour).Unix(), // Expired - URL: "https://ntfy.sh/file/old.pdf", - } - require.Nil(t, s.AddMessage(m1)) +func TestStore_DeleteScheduledBySequenceID(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Create a scheduled (unpublished) message + scheduledMsg := model.NewDefaultMessage("mytopic", "scheduled message") + scheduledMsg.ID = "scheduled1" + scheduledMsg.SequenceID = "seq123" + scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled + require.Nil(t, s.AddMessage(scheduledMsg)) - // Add a message with another expired attachment - m2 := model.NewDefaultMessage("mytopic", "another old file") - m2.ID = "msg2" - m2.SequenceID = "msg2" - m2.Expires = time.Now().Add(time.Hour).Unix() - m2.Attachment = &model.Attachment{ - Name: "another.pdf", - Type: "application/pdf", - Size: 30000, - Expires: time.Now().Add(-time.Hour).Unix(), // Expired - URL: "https://ntfy.sh/file/another.pdf", - } - require.Nil(t, s.AddMessage(m2)) + // Create a published message with different sequence ID + publishedMsg := model.NewDefaultMessage("mytopic", "published message") + publishedMsg.ID = "published1" + publishedMsg.SequenceID = "seq456" + publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published + require.Nil(t, s.AddMessage(publishedMsg)) - // Both should show as expired attachments needing cleanup - ids, err := s.AttachmentsExpired() - require.Nil(t, err) - require.Equal(t, 2, len(ids)) + // Create a scheduled message in a different topic + otherTopicMsg := model.NewDefaultMessage("othertopic", "other scheduled") + otherTopicMsg.ID = "other1" + otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg + otherTopicMsg.Time = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(otherTopicMsg)) - // Mark msg1's attachment as deleted (file cleaned up) - require.Nil(t, s.MarkAttachmentsDeleted("msg1")) + // Verify all messages exist (including scheduled) + messages, err := s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) - // Now only msg2 should show as needing cleanup - ids, err = s.AttachmentsExpired() - require.Nil(t, err) - require.Equal(t, 1, len(ids)) - require.Equal(t, "msg2", ids[0]) + messages, err = s.Messages("othertopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) - // Mark msg2 too - require.Nil(t, s.MarkAttachmentsDeleted("msg2")) + // Delete scheduled message by sequence ID and verify returned IDs + deletedIDs, err := s.DeleteScheduledBySequenceID("mytopic", "seq123") + require.Nil(t, err) + require.Equal(t, 1, len(deletedIDs)) + require.Equal(t, "scheduled1", deletedIDs[0]) - // No more expired attachments to clean up - ids, err = s.AttachmentsExpired() - require.Nil(t, err) - require.Equal(t, 0, len(ids)) + // Verify scheduled message is deleted + messages, err = s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "published message", messages[0].Message) - // Messages themselves still exist - messages, err := s.Messages("mytopic", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) + // Verify other topic's message still exists (topic-scoped deletion) + messages, err = s.Messages("othertopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "other scheduled", messages[0].Message) + + // Deleting non-existent sequence ID should return empty list + deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "nonexistent") + require.Nil(t, err) + require.Empty(t, deletedIDs) + + // Deleting published message should not affect it (only deletes unpublished) + deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "seq456") + require.Nil(t, err) + require.Empty(t, deletedIDs) + + messages, err = s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "published message", messages[0].Message) + }) } -func testStats(t *testing.T, s message.Store) { - // Initial stats should be zero - messages, err := s.Stats() - require.Nil(t, err) - require.Equal(t, int64(0), messages) +func TestStore_MessageByID(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Add a message + m := model.NewDefaultMessage("mytopic", "some message") + m.Title = "some title" + m.Priority = 4 + m.Tags = []string{"tag1", "tag2"} + require.Nil(t, s.AddMessage(m)) - // Update stats - require.Nil(t, s.UpdateStats(42)) - messages, err = s.Stats() - require.Nil(t, err) - require.Equal(t, int64(42), messages) + // Retrieve by ID + retrieved, err := s.Message(m.ID) + require.Nil(t, err) + require.Equal(t, m.ID, retrieved.ID) + require.Equal(t, "mytopic", retrieved.Topic) + require.Equal(t, "some message", retrieved.Message) + require.Equal(t, "some title", retrieved.Title) + require.Equal(t, 4, retrieved.Priority) + require.Equal(t, []string{"tag1", "tag2"}, retrieved.Tags) - // Update again (overwrites) - require.Nil(t, s.UpdateStats(100)) - messages, err = s.Stats() - require.Nil(t, err) - require.Equal(t, int64(100), messages) + // Non-existent ID returns ErrMessageNotFound + _, err = s.Message("doesnotexist") + require.Equal(t, model.ErrMessageNotFound, err) + }) } -func testAddMessages(t *testing.T, s message.Store) { - // Batch add multiple messages - msgs := []*model.Message{ - model.NewDefaultMessage("mytopic", "batch 1"), - model.NewDefaultMessage("mytopic", "batch 2"), - model.NewDefaultMessage("othertopic", "batch 3"), - } - require.Nil(t, s.AddMessages(msgs)) +func TestStore_MarkPublished(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Add a scheduled message (future time -> unpublished) + m := model.NewDefaultMessage("mytopic", "scheduled message") + m.Time = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m)) - // Verify all were inserted - messages, err := s.Messages("mytopic", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 2, len(messages)) + // Verify it does not appear in non-scheduled queries + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 0, len(messages)) - messages, err = s.Messages("othertopic", model.SinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "batch 3", messages[0].Message) + // Verify it does appear in scheduled queries + messages, err = s.Messages("mytopic", model.SinceAllMessages, true) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) - // Empty batch should succeed - require.Nil(t, s.AddMessages([]*model.Message{})) + // Mark as published + require.Nil(t, s.MarkPublished(m)) - // Batch with invalid event type should fail - badMsgs := []*model.Message{ - model.NewKeepaliveMessage("mytopic"), - } - require.NotNil(t, s.AddMessages(badMsgs)) + // Now it should appear in non-scheduled queries too + messages, err = s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "scheduled message", messages[0].Message) + }) } -func testMessagesDue(t *testing.T, s message.Store) { - // Add a message scheduled in the past (i.e. it's due now) - m1 := model.NewDefaultMessage("mytopic", "due message") - m1.Time = time.Now().Add(-time.Second).Unix() - // Set expires in the future so it doesn't get pruned - m1.Expires = time.Now().Add(time.Hour).Unix() - require.Nil(t, s.AddMessage(m1)) +func TestStore_ExpireMessages(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Add messages to two topics + m1 := model.NewDefaultMessage("topic1", "message 1") + m1.Expires = time.Now().Add(time.Hour).Unix() + m2 := model.NewDefaultMessage("topic1", "message 2") + m2.Expires = time.Now().Add(time.Hour).Unix() + m3 := model.NewDefaultMessage("topic2", "message 3") + m3.Expires = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m1)) + require.Nil(t, s.AddMessage(m2)) + require.Nil(t, s.AddMessage(m3)) - // Add a message scheduled in the future (not due) - m2 := model.NewDefaultMessage("mytopic", "future message") - m2.Time = time.Now().Add(time.Hour).Unix() - require.Nil(t, s.AddMessage(m2)) + // Verify all messages exist + messages, err := s.Messages("topic1", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + messages, err = s.Messages("topic2", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) - // Mark m1 as published so it won't be "due" - // (MessagesDue returns unpublished messages whose time <= now) - // m1 is auto-published (time <= now), so it should not be due - // m2 is unpublished (time in future), not due yet - due, err := s.MessagesDue() - require.Nil(t, err) - require.Equal(t, 0, len(due)) + // Expire topic1 messages + require.Nil(t, s.ExpireMessages("topic1")) - // Add a message that was explicitly scheduled in the past but time has "arrived" - // We need to manipulate the database to create a truly "due" message: - // a message with published=false and time <= now - m3 := model.NewDefaultMessage("mytopic", "truly due message") - m3.Time = time.Now().Add(2 * time.Second).Unix() // 2 seconds from now - require.Nil(t, s.AddMessage(m3)) + // topic1 messages should now be expired (expires set to past) + expiredIDs, err := s.MessagesExpired() + require.Nil(t, err) + require.Equal(t, 2, len(expiredIDs)) + sort.Strings(expiredIDs) + expectedIDs := []string{m1.ID, m2.ID} + sort.Strings(expectedIDs) + require.Equal(t, expectedIDs, expiredIDs) - // Not due yet - due, err = s.MessagesDue() - require.Nil(t, err) - require.Equal(t, 0, len(due)) - - // Wait for it to become due - time.Sleep(3 * time.Second) - - due, err = s.MessagesDue() - require.Nil(t, err) - require.Equal(t, 1, len(due)) - require.Equal(t, "truly due message", due[0].Message) + // topic2 should be unaffected + messages, err = s.Messages("topic2", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "message 3", messages[0].Message) + }) } -func testMessageFieldRoundTrip(t *testing.T, s message.Store) { - // Create a message with all fields populated - m := model.NewDefaultMessage("mytopic", "hello world") - m.SequenceID = "custom_seq_id" - m.Title = "A Title" - m.Priority = 4 - m.Tags = []string{"warning", "srv01"} - m.Click = "https://example.com/click" - m.Icon = "https://example.com/icon.png" - m.Actions = []*model.Action{ - { - ID: "action1", - Action: "view", - Label: "Open Site", - URL: "https://example.com", - Clear: true, - }, - { - ID: "action2", - Action: "http", - Label: "Call Webhook", - URL: "https://example.com/hook", - Method: "PUT", - Headers: map[string]string{"X-Token": "secret"}, - Body: `{"key":"value"}`, - }, - } - m.ContentType = "text/markdown" - m.Encoding = "base64" - m.Sender = netip.MustParseAddr("9.8.7.6") - m.User = "u_TestUser123" - require.Nil(t, s.AddMessage(m)) +func TestStore_MarkAttachmentsDeleted(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Add a message with an expired attachment (file needs cleanup) + m1 := model.NewDefaultMessage("mytopic", "old file") + m1.ID = "msg1" + m1.SequenceID = "msg1" + m1.Expires = time.Now().Add(time.Hour).Unix() + m1.Attachment = &model.Attachment{ + Name: "old.pdf", + Type: "application/pdf", + Size: 50000, + Expires: time.Now().Add(-time.Hour).Unix(), // Expired + URL: "https://ntfy.sh/file/old.pdf", + } + require.Nil(t, s.AddMessage(m1)) - // Retrieve and verify every field - retrieved, err := s.Message(m.ID) - require.Nil(t, err) + // Add a message with another expired attachment + m2 := model.NewDefaultMessage("mytopic", "another old file") + m2.ID = "msg2" + m2.SequenceID = "msg2" + m2.Expires = time.Now().Add(time.Hour).Unix() + m2.Attachment = &model.Attachment{ + Name: "another.pdf", + Type: "application/pdf", + Size: 30000, + Expires: time.Now().Add(-time.Hour).Unix(), // Expired + URL: "https://ntfy.sh/file/another.pdf", + } + require.Nil(t, s.AddMessage(m2)) - require.Equal(t, m.ID, retrieved.ID) - require.Equal(t, "custom_seq_id", retrieved.SequenceID) - require.Equal(t, m.Time, retrieved.Time) - require.Equal(t, m.Expires, retrieved.Expires) - require.Equal(t, model.MessageEvent, retrieved.Event) - require.Equal(t, "mytopic", retrieved.Topic) - require.Equal(t, "hello world", retrieved.Message) - require.Equal(t, "A Title", retrieved.Title) - require.Equal(t, 4, retrieved.Priority) - require.Equal(t, []string{"warning", "srv01"}, retrieved.Tags) - require.Equal(t, "https://example.com/click", retrieved.Click) - require.Equal(t, "https://example.com/icon.png", retrieved.Icon) - require.Equal(t, "text/markdown", retrieved.ContentType) - require.Equal(t, "base64", retrieved.Encoding) - require.Equal(t, netip.MustParseAddr("9.8.7.6"), retrieved.Sender) - require.Equal(t, "u_TestUser123", retrieved.User) + // Both should show as expired attachments needing cleanup + ids, err := s.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, 2, len(ids)) - // Verify actions round-trip - require.Equal(t, 2, len(retrieved.Actions)) + // Mark msg1's attachment as deleted (file cleaned up) + require.Nil(t, s.MarkAttachmentsDeleted("msg1")) - require.Equal(t, "action1", retrieved.Actions[0].ID) - require.Equal(t, "view", retrieved.Actions[0].Action) - require.Equal(t, "Open Site", retrieved.Actions[0].Label) - require.Equal(t, "https://example.com", retrieved.Actions[0].URL) - require.Equal(t, true, retrieved.Actions[0].Clear) + // Now only msg2 should show as needing cleanup + ids, err = s.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, 1, len(ids)) + require.Equal(t, "msg2", ids[0]) - require.Equal(t, "action2", retrieved.Actions[1].ID) - require.Equal(t, "http", retrieved.Actions[1].Action) - require.Equal(t, "Call Webhook", retrieved.Actions[1].Label) - require.Equal(t, "https://example.com/hook", retrieved.Actions[1].URL) - require.Equal(t, "PUT", retrieved.Actions[1].Method) - require.Equal(t, "secret", retrieved.Actions[1].Headers["X-Token"]) - require.Equal(t, `{"key":"value"}`, retrieved.Actions[1].Body) + // Mark msg2 too + require.Nil(t, s.MarkAttachmentsDeleted("msg2")) + + // No more expired attachments to clean up + ids, err = s.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, 0, len(ids)) + + // Messages themselves still exist + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + }) +} + +func TestStore_Stats(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Initial stats should be zero + messages, err := s.Stats() + require.Nil(t, err) + require.Equal(t, int64(0), messages) + + // Update stats + require.Nil(t, s.UpdateStats(42)) + messages, err = s.Stats() + require.Nil(t, err) + require.Equal(t, int64(42), messages) + + // Update again (overwrites) + require.Nil(t, s.UpdateStats(100)) + messages, err = s.Stats() + require.Nil(t, err) + require.Equal(t, int64(100), messages) + }) +} + +func TestStore_AddMessages(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Batch add multiple messages + msgs := []*model.Message{ + model.NewDefaultMessage("mytopic", "batch 1"), + model.NewDefaultMessage("mytopic", "batch 2"), + model.NewDefaultMessage("othertopic", "batch 3"), + } + require.Nil(t, s.AddMessages(msgs)) + + // Verify all were inserted + messages, err := s.Messages("mytopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + + messages, err = s.Messages("othertopic", model.SinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "batch 3", messages[0].Message) + + // Empty batch should succeed + require.Nil(t, s.AddMessages([]*model.Message{})) + + // Batch with invalid event type should fail + badMsgs := []*model.Message{ + model.NewKeepaliveMessage("mytopic"), + } + require.NotNil(t, s.AddMessages(badMsgs)) + }) +} + +func TestStore_MessagesDue(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Add a message scheduled in the past (i.e. it's due now) + m1 := model.NewDefaultMessage("mytopic", "due message") + m1.Time = time.Now().Add(-time.Second).Unix() + // Set expires in the future so it doesn't get pruned + m1.Expires = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m1)) + + // Add a message scheduled in the future (not due) + m2 := model.NewDefaultMessage("mytopic", "future message") + m2.Time = time.Now().Add(time.Hour).Unix() + require.Nil(t, s.AddMessage(m2)) + + // Mark m1 as published so it won't be "due" + // (MessagesDue returns unpublished messages whose time <= now) + // m1 is auto-published (time <= now), so it should not be due + // m2 is unpublished (time in future), not due yet + due, err := s.MessagesDue() + require.Nil(t, err) + require.Equal(t, 0, len(due)) + + // Add a message that was explicitly scheduled in the past but time has "arrived" + // We need to manipulate the database to create a truly "due" message: + // a message with published=false and time <= now + m3 := model.NewDefaultMessage("mytopic", "truly due message") + m3.Time = time.Now().Add(2 * time.Second).Unix() // 2 seconds from now + require.Nil(t, s.AddMessage(m3)) + + // Not due yet + due, err = s.MessagesDue() + require.Nil(t, err) + require.Equal(t, 0, len(due)) + + // Wait for it to become due + time.Sleep(3 * time.Second) + + due, err = s.MessagesDue() + require.Nil(t, err) + require.Equal(t, 1, len(due)) + require.Equal(t, "truly due message", due[0].Message) + }) +} + +func TestStore_MessageFieldRoundTrip(t *testing.T) { + forEachBackend(t, func(t *testing.T, s message.Store) { + // Create a message with all fields populated + m := model.NewDefaultMessage("mytopic", "hello world") + m.SequenceID = "custom_seq_id" + m.Title = "A Title" + m.Priority = 4 + m.Tags = []string{"warning", "srv01"} + m.Click = "https://example.com/click" + m.Icon = "https://example.com/icon.png" + m.Actions = []*model.Action{ + { + ID: "action1", + Action: "view", + Label: "Open Site", + URL: "https://example.com", + Clear: true, + }, + { + ID: "action2", + Action: "http", + Label: "Call Webhook", + URL: "https://example.com/hook", + Method: "PUT", + Headers: map[string]string{"X-Token": "secret"}, + Body: `{"key":"value"}`, + }, + } + m.ContentType = "text/markdown" + m.Encoding = "base64" + m.Sender = netip.MustParseAddr("9.8.7.6") + m.User = "u_TestUser123" + require.Nil(t, s.AddMessage(m)) + + // Retrieve and verify every field + retrieved, err := s.Message(m.ID) + require.Nil(t, err) + + require.Equal(t, m.ID, retrieved.ID) + require.Equal(t, "custom_seq_id", retrieved.SequenceID) + require.Equal(t, m.Time, retrieved.Time) + require.Equal(t, m.Expires, retrieved.Expires) + require.Equal(t, model.MessageEvent, retrieved.Event) + require.Equal(t, "mytopic", retrieved.Topic) + require.Equal(t, "hello world", retrieved.Message) + require.Equal(t, "A Title", retrieved.Title) + require.Equal(t, 4, retrieved.Priority) + require.Equal(t, []string{"warning", "srv01"}, retrieved.Tags) + require.Equal(t, "https://example.com/click", retrieved.Click) + require.Equal(t, "https://example.com/icon.png", retrieved.Icon) + require.Equal(t, "text/markdown", retrieved.ContentType) + require.Equal(t, "base64", retrieved.Encoding) + require.Equal(t, netip.MustParseAddr("9.8.7.6"), retrieved.Sender) + require.Equal(t, "u_TestUser123", retrieved.User) + + // Verify actions round-trip + require.Equal(t, 2, len(retrieved.Actions)) + + require.Equal(t, "action1", retrieved.Actions[0].ID) + require.Equal(t, "view", retrieved.Actions[0].Action) + require.Equal(t, "Open Site", retrieved.Actions[0].Label) + require.Equal(t, "https://example.com", retrieved.Actions[0].URL) + require.Equal(t, true, retrieved.Actions[0].Clear) + + require.Equal(t, "action2", retrieved.Actions[1].ID) + require.Equal(t, "http", retrieved.Actions[1].Action) + require.Equal(t, "Call Webhook", retrieved.Actions[1].Label) + require.Equal(t, "https://example.com/hook", retrieved.Actions[1].URL) + require.Equal(t, "PUT", retrieved.Actions[1].Method) + require.Equal(t, "secret", retrieved.Actions[1].Headers["X-Token"]) + require.Equal(t, `{"key":"value"}`, retrieved.Actions[1].Body) + }) } diff --git a/user/store_postgres_test.go b/user/store_postgres_test.go deleted file mode 100644 index e3902ea2..00000000 --- a/user/store_postgres_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package user_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - dbtest "heckel.io/ntfy/v2/db/test" - "heckel.io/ntfy/v2/user" -) - -func newTestPostgresStore(t *testing.T) user.Store { - testDB := dbtest.CreateTestPostgres(t) - store, err := user.NewPostgresStore(testDB) - require.Nil(t, err) - return store -} - -func TestPostgresStoreAddUser(t *testing.T) { - testStoreAddUser(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreAddUserAlreadyExists(t *testing.T) { - testStoreAddUserAlreadyExists(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreRemoveUser(t *testing.T) { - testStoreRemoveUser(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUserByID(t *testing.T) { - testStoreUserByID(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUserByToken(t *testing.T) { - testStoreUserByToken(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUserByStripeCustomer(t *testing.T) { - testStoreUserByStripeCustomer(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUsers(t *testing.T) { - testStoreUsers(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUsersCount(t *testing.T) { - testStoreUsersCount(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreChangePassword(t *testing.T) { - testStoreChangePassword(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreChangeRole(t *testing.T) { - testStoreChangeRole(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTokens(t *testing.T) { - testStoreTokens(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTokenChangeLabel(t *testing.T) { - testStoreTokenChangeLabel(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTokenRemove(t *testing.T) { - testStoreTokenRemove(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTokenRemoveExpired(t *testing.T) { - testStoreTokenRemoveExpired(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTokenRemoveExcess(t *testing.T) { - testStoreTokenRemoveExcess(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTokenUpdateLastAccess(t *testing.T) { - testStoreTokenUpdateLastAccess(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreAllowAccess(t *testing.T) { - testStoreAllowAccess(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreAllowAccessReadOnly(t *testing.T) { - testStoreAllowAccessReadOnly(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreResetAccess(t *testing.T) { - testStoreResetAccess(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreResetAccessAll(t *testing.T) { - testStoreResetAccessAll(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreAuthorizeTopicAccess(t *testing.T) { - testStoreAuthorizeTopicAccess(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreAuthorizeTopicAccessNotFound(t *testing.T) { - testStoreAuthorizeTopicAccessNotFound(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreAuthorizeTopicAccessDenyAll(t *testing.T) { - testStoreAuthorizeTopicAccessDenyAll(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreReservations(t *testing.T) { - testStoreReservations(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreReservationsCount(t *testing.T) { - testStoreReservationsCount(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreHasReservation(t *testing.T) { - testStoreHasReservation(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreReservationOwner(t *testing.T) { - testStoreReservationOwner(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTiers(t *testing.T) { - testStoreTiers(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTierUpdate(t *testing.T) { - testStoreTierUpdate(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTierRemove(t *testing.T) { - testStoreTierRemove(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreTierByStripePrice(t *testing.T) { - testStoreTierByStripePrice(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreChangeTier(t *testing.T) { - testStoreChangeTier(t, newTestPostgresStore(t)) -} - -func TestPostgresStorePhoneNumbers(t *testing.T) { - testStorePhoneNumbers(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreChangeSettings(t *testing.T) { - testStoreChangeSettings(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreChangeBilling(t *testing.T) { - testStoreChangeBilling(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUpdateStats(t *testing.T) { - testStoreUpdateStats(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreResetStats(t *testing.T) { - testStoreResetStats(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreMarkUserRemoved(t *testing.T) { - testStoreMarkUserRemoved(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreRemoveDeletedUsers(t *testing.T) { - testStoreRemoveDeletedUsers(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreAllGrants(t *testing.T) { - testStoreAllGrants(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreOtherAccessCount(t *testing.T) { - testStoreOtherAccessCount(t, newTestPostgresStore(t)) -} diff --git a/user/store_sqlite_test.go b/user/store_sqlite_test.go deleted file mode 100644 index 9f9e5a71..00000000 --- a/user/store_sqlite_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package user_test - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/user" -) - -func newTestSQLiteStore(t *testing.T) user.Store { - store, err := user.NewSQLiteStore(filepath.Join(t.TempDir(), "user.db"), "") - require.Nil(t, err) - t.Cleanup(func() { store.Close() }) - return store -} - -func TestSQLiteStoreAddUser(t *testing.T) { - testStoreAddUser(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreAddUserAlreadyExists(t *testing.T) { - testStoreAddUserAlreadyExists(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreRemoveUser(t *testing.T) { - testStoreRemoveUser(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUserByID(t *testing.T) { - testStoreUserByID(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUserByToken(t *testing.T) { - testStoreUserByToken(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUserByStripeCustomer(t *testing.T) { - testStoreUserByStripeCustomer(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUsers(t *testing.T) { - testStoreUsers(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUsersCount(t *testing.T) { - testStoreUsersCount(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreChangePassword(t *testing.T) { - testStoreChangePassword(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreChangeRole(t *testing.T) { - testStoreChangeRole(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTokens(t *testing.T) { - testStoreTokens(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTokenChangeLabel(t *testing.T) { - testStoreTokenChangeLabel(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTokenRemove(t *testing.T) { - testStoreTokenRemove(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTokenRemoveExpired(t *testing.T) { - testStoreTokenRemoveExpired(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTokenRemoveExcess(t *testing.T) { - testStoreTokenRemoveExcess(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTokenUpdateLastAccess(t *testing.T) { - testStoreTokenUpdateLastAccess(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreAllowAccess(t *testing.T) { - testStoreAllowAccess(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreAllowAccessReadOnly(t *testing.T) { - testStoreAllowAccessReadOnly(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreResetAccess(t *testing.T) { - testStoreResetAccess(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreResetAccessAll(t *testing.T) { - testStoreResetAccessAll(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreAuthorizeTopicAccess(t *testing.T) { - testStoreAuthorizeTopicAccess(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreAuthorizeTopicAccessNotFound(t *testing.T) { - testStoreAuthorizeTopicAccessNotFound(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreAuthorizeTopicAccessDenyAll(t *testing.T) { - testStoreAuthorizeTopicAccessDenyAll(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreReservations(t *testing.T) { - testStoreReservations(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreReservationsCount(t *testing.T) { - testStoreReservationsCount(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreHasReservation(t *testing.T) { - testStoreHasReservation(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreReservationOwner(t *testing.T) { - testStoreReservationOwner(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTiers(t *testing.T) { - testStoreTiers(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTierUpdate(t *testing.T) { - testStoreTierUpdate(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTierRemove(t *testing.T) { - testStoreTierRemove(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreTierByStripePrice(t *testing.T) { - testStoreTierByStripePrice(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreChangeTier(t *testing.T) { - testStoreChangeTier(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStorePhoneNumbers(t *testing.T) { - testStorePhoneNumbers(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreChangeSettings(t *testing.T) { - testStoreChangeSettings(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreChangeBilling(t *testing.T) { - testStoreChangeBilling(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUpdateStats(t *testing.T) { - testStoreUpdateStats(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreResetStats(t *testing.T) { - testStoreResetStats(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreMarkUserRemoved(t *testing.T) { - testStoreMarkUserRemoved(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreRemoveDeletedUsers(t *testing.T) { - testStoreRemoveDeletedUsers(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreAllGrants(t *testing.T) { - testStoreAllGrants(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreOtherAccessCount(t *testing.T) { - testStoreOtherAccessCount(t, newTestSQLiteStore(t)) -} diff --git a/user/store_test.go b/user/store_test.go index da012bf2..7dc3ef38 100644 --- a/user/store_test.go +++ b/user/store_test.go @@ -2,618 +2,717 @@ package user_test import ( "net/netip" + "path/filepath" "testing" "time" "github.com/stretchr/testify/require" + dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/user" ) -func testStoreAddUser(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, "phil", u.Name) - require.Equal(t, user.RoleUser, u.Role) - require.False(t, u.Provisioned) - require.NotEmpty(t, u.ID) - require.NotEmpty(t, u.SyncTopic) -} - -func testStoreAddUserAlreadyExists(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Equal(t, user.ErrUserExists, store.AddUser("phil", "philhash", user.RoleUser, false)) -} - -func testStoreRemoveUser(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, "phil", u.Name) - - require.Nil(t, store.RemoveUser("phil")) - _, err = store.User("phil") - require.Equal(t, user.ErrUserNotFound, err) -} - -func testStoreUserByID(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleAdmin, false)) - u, err := store.User("phil") - require.Nil(t, err) - - u2, err := store.UserByID(u.ID) - require.Nil(t, err) - require.Equal(t, u.Name, u2.Name) - require.Equal(t, u.ID, u2.ID) -} - -func testStoreUserByToken(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - tk, err := store.CreateToken(u.ID, "tk_test123", "test token", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(24*time.Hour), false) - require.Nil(t, err) - require.Equal(t, "tk_test123", tk.Value) - - u2, err := store.UserByToken(tk.Value) - require.Nil(t, err) - require.Equal(t, "phil", u2.Name) -} - -func testStoreUserByStripeCustomer(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.ChangeBilling("phil", &user.Billing{ - StripeCustomerID: "cus_test123", - StripeSubscriptionID: "sub_test123", - })) - - u, err := store.UserByStripeCustomer("cus_test123") - require.Nil(t, err) - require.Equal(t, "phil", u.Name) - require.Equal(t, "cus_test123", u.Billing.StripeCustomerID) -} - -func testStoreUsers(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AddUser("ben", "benhash", user.RoleAdmin, false)) - - users, err := store.Users() - require.Nil(t, err) - require.True(t, len(users) >= 3) // phil, ben, and the everyone user -} - -func testStoreUsersCount(t *testing.T, store user.Store) { - count, err := store.UsersCount() - require.Nil(t, err) - require.True(t, count >= 1) // At least the everyone user - - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - count2, err := store.UsersCount() - require.Nil(t, err) - require.Equal(t, count+1, count2) -} - -func testStoreChangePassword(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, "philhash", u.Hash) - - require.Nil(t, store.ChangePassword("phil", "newhash")) - u, err = store.User("phil") - require.Nil(t, err) - require.Equal(t, "newhash", u.Hash) -} - -func testStoreChangeRole(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, user.RoleUser, u.Role) - - require.Nil(t, store.ChangeRole("phil", user.RoleAdmin)) - u, err = store.User("phil") - require.Nil(t, err) - require.Equal(t, user.RoleAdmin, u.Role) -} - -func testStoreTokens(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - now := time.Now() - expires := now.Add(24 * time.Hour) - origin := netip.MustParseAddr("9.9.9.9") - - tk, err := store.CreateToken(u.ID, "tk_abc", "my token", now, origin, expires, false) - require.Nil(t, err) - require.Equal(t, "tk_abc", tk.Value) - require.Equal(t, "my token", tk.Label) - - // Get single token - tk2, err := store.Token(u.ID, "tk_abc") - require.Nil(t, err) - require.Equal(t, "tk_abc", tk2.Value) - require.Equal(t, "my token", tk2.Label) - - // Get all tokens - tokens, err := store.Tokens(u.ID) - require.Nil(t, err) - require.Len(t, tokens, 1) - require.Equal(t, "tk_abc", tokens[0].Value) - - // Token count - count, err := store.TokenCount(u.ID) - require.Nil(t, err) - require.Equal(t, 1, count) -} - -func testStoreTokenChangeLabel(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - _, err = store.CreateToken(u.ID, "tk_abc", "old label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) - require.Nil(t, err) - - require.Nil(t, store.ChangeTokenLabel(u.ID, "tk_abc", "new label")) - tk, err := store.Token(u.ID, "tk_abc") - require.Nil(t, err) - require.Equal(t, "new label", tk.Label) -} - -func testStoreTokenRemove(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) - require.Nil(t, err) - - require.Nil(t, store.RemoveToken(u.ID, "tk_abc")) - _, err = store.Token(u.ID, "tk_abc") - require.Equal(t, user.ErrTokenNotFound, err) -} - -func testStoreTokenRemoveExpired(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - // Create expired token and active token - _, err = store.CreateToken(u.ID, "tk_expired", "expired", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(-time.Hour), false) - require.Nil(t, err) - _, err = store.CreateToken(u.ID, "tk_active", "active", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) - require.Nil(t, err) - - require.Nil(t, store.RemoveExpiredTokens()) - - // Expired token should be gone - _, err = store.Token(u.ID, "tk_expired") - require.Equal(t, user.ErrTokenNotFound, err) - - // Active token should still exist - tk, err := store.Token(u.ID, "tk_active") - require.Nil(t, err) - require.Equal(t, "tk_active", tk.Value) -} - -func testStoreTokenRemoveExcess(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - // Create 3 tokens with increasing expiry - for i, name := range []string{"tk_a", "tk_b", "tk_c"} { - _, err = store.CreateToken(u.ID, name, name, time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Duration(i+1)*time.Hour), false) +func forEachStoreBackend(t *testing.T, f func(t *testing.T, store user.Store)) { + t.Run("sqlite", func(t *testing.T) { + store, err := user.NewSQLiteStore(filepath.Join(t.TempDir(), "user.db"), "") require.Nil(t, err) - } - - count, err := store.TokenCount(u.ID) - require.Nil(t, err) - require.Equal(t, 3, count) - - // Remove excess, keep only 2 (the ones with latest expiry: tk_b, tk_c) - require.Nil(t, store.RemoveExcessTokens(u.ID, 2)) - - count, err = store.TokenCount(u.ID) - require.Nil(t, err) - require.Equal(t, 2, count) - - // tk_a should be removed (earliest expiry) - _, err = store.Token(u.ID, "tk_a") - require.Equal(t, user.ErrTokenNotFound, err) - - // tk_b and tk_c should remain - _, err = store.Token(u.ID, "tk_b") - require.Nil(t, err) - _, err = store.Token(u.ID, "tk_c") - require.Nil(t, err) + t.Cleanup(func() { store.Close() }) + f(t, store) + }) + t.Run("postgres", func(t *testing.T) { + testDB := dbtest.CreateTestPostgres(t) + store, err := user.NewPostgresStore(testDB) + require.Nil(t, err) + f(t, store) + }) } -func testStoreTokenUpdateLastAccess(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) - require.Nil(t, err) - - newTime := time.Now().Add(5 * time.Minute) - newOrigin := netip.MustParseAddr("5.5.5.5") - require.Nil(t, store.UpdateTokenLastAccess("tk_abc", newTime, newOrigin)) - - tk, err := store.Token(u.ID, "tk_abc") - require.Nil(t, err) - require.Equal(t, newTime.Unix(), tk.LastAccess.Unix()) - require.Equal(t, newOrigin, tk.LastOrigin) +func TestStoreAddUser(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) + require.Equal(t, user.RoleUser, u.Role) + require.False(t, u.Provisioned) + require.NotEmpty(t, u.ID) + require.NotEmpty(t, u.SyncTopic) + }) } -func testStoreAllowAccess(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false)) - grants, err := store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 1) - require.Equal(t, "mytopic", grants[0].TopicPattern) - require.True(t, grants[0].Permission.IsReadWrite()) +func TestStoreAddUserAlreadyExists(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Equal(t, user.ErrUserExists, store.AddUser("phil", "philhash", user.RoleUser, false)) + }) } -func testStoreAllowAccessReadOnly(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) +func TestStoreRemoveUser(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) - require.Nil(t, store.AllowAccess("phil", "announcements", true, false, "", false)) - grants, err := store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 1) - require.True(t, grants[0].Permission.IsRead()) - require.False(t, grants[0].Permission.IsWrite()) + require.Nil(t, store.RemoveUser("phil")) + _, err = store.User("phil") + require.Equal(t, user.ErrUserNotFound, err) + }) } -func testStoreResetAccess(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) - require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false)) +func TestStoreUserByID(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleAdmin, false)) + u, err := store.User("phil") + require.Nil(t, err) - grants, err := store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 2) - - require.Nil(t, store.ResetAccess("phil", "topic1")) - grants, err = store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 1) - require.Equal(t, "topic2", grants[0].TopicPattern) + u2, err := store.UserByID(u.ID) + require.Nil(t, err) + require.Equal(t, u.Name, u2.Name) + require.Equal(t, u.ID, u2.ID) + }) } -func testStoreResetAccessAll(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) - require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false)) +func TestStoreUserByToken(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) - require.Nil(t, store.ResetAccess("phil", "")) - grants, err := store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 0) + tk, err := store.CreateToken(u.ID, "tk_test123", "test token", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(24*time.Hour), false) + require.Nil(t, err) + require.Equal(t, "tk_test123", tk.Value) + + u2, err := store.UserByToken(tk.Value) + require.Nil(t, err) + require.Equal(t, "phil", u2.Name) + }) } -func testStoreAuthorizeTopicAccess(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false)) +func TestStoreUserByStripeCustomer(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.ChangeBilling("phil", &user.Billing{ + StripeCustomerID: "cus_test123", + StripeSubscriptionID: "sub_test123", + })) - read, write, found, err := store.AuthorizeTopicAccess("phil", "mytopic") - require.Nil(t, err) - require.True(t, found) - require.True(t, read) - require.True(t, write) + u, err := store.UserByStripeCustomer("cus_test123") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) + require.Equal(t, "cus_test123", u.Billing.StripeCustomerID) + }) } -func testStoreAuthorizeTopicAccessNotFound(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) +func TestStoreUsers(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AddUser("ben", "benhash", user.RoleAdmin, false)) - _, _, found, err := store.AuthorizeTopicAccess("phil", "other") - require.Nil(t, err) - require.False(t, found) + users, err := store.Users() + require.Nil(t, err) + require.True(t, len(users) >= 3) // phil, ben, and the everyone user + }) } -func testStoreAuthorizeTopicAccessDenyAll(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "secret", false, false, "", false)) +func TestStoreUsersCount(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + count, err := store.UsersCount() + require.Nil(t, err) + require.True(t, count >= 1) // At least the everyone user - read, write, found, err := store.AuthorizeTopicAccess("phil", "secret") - require.Nil(t, err) - require.True(t, found) - require.False(t, read) - require.False(t, write) + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + count2, err := store.UsersCount() + require.Nil(t, err) + require.Equal(t, count+1, count2) + }) } -func testStoreReservations(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) - require.Nil(t, store.AllowAccess(user.Everyone, "mytopic", true, false, "phil", false)) +func TestStoreChangePassword(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, "philhash", u.Hash) - reservations, err := store.Reservations("phil") - require.Nil(t, err) - require.Len(t, reservations, 1) - require.Equal(t, "mytopic", reservations[0].Topic) - require.True(t, reservations[0].Owner.IsReadWrite()) - require.True(t, reservations[0].Everyone.IsRead()) - require.False(t, reservations[0].Everyone.IsWrite()) + require.Nil(t, store.ChangePassword("phil", "newhash")) + u, err = store.User("phil") + require.Nil(t, err) + require.Equal(t, "newhash", u.Hash) + }) } -func testStoreReservationsCount(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "phil", false)) - require.Nil(t, store.AllowAccess("phil", "topic2", true, true, "phil", false)) +func TestStoreChangeRole(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, user.RoleUser, u.Role) - count, err := store.ReservationsCount("phil") - require.Nil(t, err) - require.Equal(t, int64(2), count) + require.Nil(t, store.ChangeRole("phil", user.RoleAdmin)) + u, err = store.User("phil") + require.Nil(t, err) + require.Equal(t, user.RoleAdmin, u.Role) + }) } -func testStoreHasReservation(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) +func TestStoreTokens(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) - has, err := store.HasReservation("phil", "mytopic") - require.Nil(t, err) - require.True(t, has) + now := time.Now() + expires := now.Add(24 * time.Hour) + origin := netip.MustParseAddr("9.9.9.9") - has, err = store.HasReservation("phil", "other") - require.Nil(t, err) - require.False(t, has) + tk, err := store.CreateToken(u.ID, "tk_abc", "my token", now, origin, expires, false) + require.Nil(t, err) + require.Equal(t, "tk_abc", tk.Value) + require.Equal(t, "my token", tk.Label) + + // Get single token + tk2, err := store.Token(u.ID, "tk_abc") + require.Nil(t, err) + require.Equal(t, "tk_abc", tk2.Value) + require.Equal(t, "my token", tk2.Label) + + // Get all tokens + tokens, err := store.Tokens(u.ID) + require.Nil(t, err) + require.Len(t, tokens, 1) + require.Equal(t, "tk_abc", tokens[0].Value) + + // Token count + count, err := store.TokenCount(u.ID) + require.Nil(t, err) + require.Equal(t, 1, count) + }) } -func testStoreReservationOwner(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) +func TestStoreTokenChangeLabel(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) - owner, err := store.ReservationOwner("mytopic") - require.Nil(t, err) - require.NotEmpty(t, owner) // Returns the user ID + _, err = store.CreateToken(u.ID, "tk_abc", "old label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + require.Nil(t, err) - owner, err = store.ReservationOwner("unowned") - require.Nil(t, err) - require.Empty(t, owner) + require.Nil(t, store.ChangeTokenLabel(u.ID, "tk_abc", "new label")) + tk, err := store.Token(u.ID, "tk_abc") + require.Nil(t, err) + require.Equal(t, "new label", tk.Label) + }) } -func testStoreTiers(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - MessageLimit: 5000, - MessageExpiryDuration: 24 * time.Hour, - EmailLimit: 100, - CallLimit: 10, - ReservationLimit: 20, - AttachmentFileSizeLimit: 10 * 1024 * 1024, - AttachmentTotalSizeLimit: 100 * 1024 * 1024, - AttachmentExpiryDuration: 48 * time.Hour, - AttachmentBandwidthLimit: 500 * 1024 * 1024, - } - require.Nil(t, store.AddTier(tier)) +func TestStoreTokenRemove(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) - // Get by code - t2, err := store.Tier("pro") - require.Nil(t, err) - require.Equal(t, "ti_test", t2.ID) - require.Equal(t, "pro", t2.Code) - require.Equal(t, "Pro", t2.Name) - require.Equal(t, int64(5000), t2.MessageLimit) - require.Equal(t, int64(100), t2.EmailLimit) - require.Equal(t, int64(10), t2.CallLimit) - require.Equal(t, int64(20), t2.ReservationLimit) + _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + require.Nil(t, err) - // List all tiers - tiers, err := store.Tiers() - require.Nil(t, err) - require.Len(t, tiers, 1) - require.Equal(t, "pro", tiers[0].Code) + require.Nil(t, store.RemoveToken(u.ID, "tk_abc")) + _, err = store.Token(u.ID, "tk_abc") + require.Equal(t, user.ErrTokenNotFound, err) + }) } -func testStoreTierUpdate(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - } - require.Nil(t, store.AddTier(tier)) +func TestStoreTokenRemoveExpired(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) - tier.Name = "Professional" - tier.MessageLimit = 9999 - require.Nil(t, store.UpdateTier(tier)) + // Create expired token and active token + _, err = store.CreateToken(u.ID, "tk_expired", "expired", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(-time.Hour), false) + require.Nil(t, err) + _, err = store.CreateToken(u.ID, "tk_active", "active", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + require.Nil(t, err) - t2, err := store.Tier("pro") - require.Nil(t, err) - require.Equal(t, "Professional", t2.Name) - require.Equal(t, int64(9999), t2.MessageLimit) + require.Nil(t, store.RemoveExpiredTokens()) + + // Expired token should be gone + _, err = store.Token(u.ID, "tk_expired") + require.Equal(t, user.ErrTokenNotFound, err) + + // Active token should still exist + tk, err := store.Token(u.ID, "tk_active") + require.Nil(t, err) + require.Equal(t, "tk_active", tk.Value) + }) } -func testStoreTierRemove(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - } - require.Nil(t, store.AddTier(tier)) +func TestStoreTokenRemoveExcess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) - t2, err := store.Tier("pro") - require.Nil(t, err) - require.Equal(t, "pro", t2.Code) + // Create 3 tokens with increasing expiry + for i, name := range []string{"tk_a", "tk_b", "tk_c"} { + _, err = store.CreateToken(u.ID, name, name, time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Duration(i+1)*time.Hour), false) + require.Nil(t, err) + } - require.Nil(t, store.RemoveTier("pro")) - _, err = store.Tier("pro") - require.Equal(t, user.ErrTierNotFound, err) + count, err := store.TokenCount(u.ID) + require.Nil(t, err) + require.Equal(t, 3, count) + + // Remove excess, keep only 2 (the ones with latest expiry: tk_b, tk_c) + require.Nil(t, store.RemoveExcessTokens(u.ID, 2)) + + count, err = store.TokenCount(u.ID) + require.Nil(t, err) + require.Equal(t, 2, count) + + // tk_a should be removed (earliest expiry) + _, err = store.Token(u.ID, "tk_a") + require.Equal(t, user.ErrTokenNotFound, err) + + // tk_b and tk_c should remain + _, err = store.Token(u.ID, "tk_b") + require.Nil(t, err) + _, err = store.Token(u.ID, "tk_c") + require.Nil(t, err) + }) } -func testStoreTierByStripePrice(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - StripeMonthlyPriceID: "price_monthly", - StripeYearlyPriceID: "price_yearly", - } - require.Nil(t, store.AddTier(tier)) +func TestStoreTokenUpdateLastAccess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) - t2, err := store.TierByStripePrice("price_monthly") - require.Nil(t, err) - require.Equal(t, "pro", t2.Code) + _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + require.Nil(t, err) - t3, err := store.TierByStripePrice("price_yearly") - require.Nil(t, err) - require.Equal(t, "pro", t3.Code) + newTime := time.Now().Add(5 * time.Minute) + newOrigin := netip.MustParseAddr("5.5.5.5") + require.Nil(t, store.UpdateTokenLastAccess("tk_abc", newTime, newOrigin)) + + tk, err := store.Token(u.ID, "tk_abc") + require.Nil(t, err) + require.Equal(t, newTime.Unix(), tk.LastAccess.Unix()) + require.Equal(t, newOrigin, tk.LastOrigin) + }) } -func testStoreChangeTier(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - } - require.Nil(t, store.AddTier(tier)) - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.ChangeTier("phil", "pro")) +func TestStoreAllowAccess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.NotNil(t, u.Tier) - require.Equal(t, "pro", u.Tier.Code) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false)) + grants, err := store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.Equal(t, "mytopic", grants[0].TopicPattern) + require.True(t, grants[0].Permission.IsReadWrite()) + }) } -func testStorePhoneNumbers(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) +func TestStoreAllowAccessReadOnly(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AddPhoneNumber(u.ID, "+1234567890")) - require.Nil(t, store.AddPhoneNumber(u.ID, "+0987654321")) - - numbers, err := store.PhoneNumbers(u.ID) - require.Nil(t, err) - require.Len(t, numbers, 2) - - require.Nil(t, store.RemovePhoneNumber(u.ID, "+1234567890")) - numbers, err = store.PhoneNumbers(u.ID) - require.Nil(t, err) - require.Len(t, numbers, 1) - require.Equal(t, "+0987654321", numbers[0]) + require.Nil(t, store.AllowAccess("phil", "announcements", true, false, "", false)) + grants, err := store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.True(t, grants[0].Permission.IsRead()) + require.False(t, grants[0].Permission.IsWrite()) + }) } -func testStoreChangeSettings(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) +func TestStoreResetAccess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) + require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false)) - lang := "de" - prefs := &user.Prefs{Language: &lang} - require.Nil(t, store.ChangeSettings(u.ID, prefs)) + grants, err := store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 2) - u2, err := store.User("phil") - require.Nil(t, err) - require.NotNil(t, u2.Prefs) - require.Equal(t, "de", *u2.Prefs.Language) + require.Nil(t, store.ResetAccess("phil", "topic1")) + grants, err = store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.Equal(t, "topic2", grants[0].TopicPattern) + }) } -func testStoreChangeBilling(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) +func TestStoreResetAccessAll(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) + require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false)) - billing := &user.Billing{ - StripeCustomerID: "cus_123", - StripeSubscriptionID: "sub_456", - } - require.Nil(t, store.ChangeBilling("phil", billing)) - - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, "cus_123", u.Billing.StripeCustomerID) - require.Equal(t, "sub_456", u.Billing.StripeSubscriptionID) + require.Nil(t, store.ResetAccess("phil", "")) + grants, err := store.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 0) + }) } -func testStoreUpdateStats(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) +func TestStoreAuthorizeTopicAccess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false)) - stats := &user.Stats{Messages: 42, Emails: 3, Calls: 1} - require.Nil(t, store.UpdateStats(u.ID, stats)) - - u2, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, int64(42), u2.Stats.Messages) - require.Equal(t, int64(3), u2.Stats.Emails) - require.Equal(t, int64(1), u2.Stats.Calls) + read, write, found, err := store.AuthorizeTopicAccess("phil", "mytopic") + require.Nil(t, err) + require.True(t, found) + require.True(t, read) + require.True(t, write) + }) } -func testStoreResetStats(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) +func TestStoreAuthorizeTopicAccessNotFound(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.UpdateStats(u.ID, &user.Stats{Messages: 42, Emails: 3, Calls: 1})) - require.Nil(t, store.ResetStats()) - - u2, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, int64(0), u2.Stats.Messages) - require.Equal(t, int64(0), u2.Stats.Emails) - require.Equal(t, int64(0), u2.Stats.Calls) + _, _, found, err := store.AuthorizeTopicAccess("phil", "other") + require.Nil(t, err) + require.False(t, found) + }) } -func testStoreMarkUserRemoved(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) +func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "secret", false, false, "", false)) - require.Nil(t, store.MarkUserRemoved(u.ID)) - - u2, err := store.User("phil") - require.Nil(t, err) - require.True(t, u2.Deleted) + read, write, found, err := store.AuthorizeTopicAccess("phil", "secret") + require.Nil(t, err) + require.True(t, found) + require.False(t, read) + require.False(t, write) + }) } -func testStoreRemoveDeletedUsers(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) +func TestStoreReservations(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) + require.Nil(t, store.AllowAccess(user.Everyone, "mytopic", true, false, "phil", false)) - require.Nil(t, store.MarkUserRemoved(u.ID)) - - // RemoveDeletedUsers only removes users past the hard-delete duration (7 days). - // Immediately after marking, the user should still exist. - require.Nil(t, store.RemoveDeletedUsers()) - u2, err := store.User("phil") - require.Nil(t, err) - require.True(t, u2.Deleted) + reservations, err := store.Reservations("phil") + require.Nil(t, err) + require.Len(t, reservations, 1) + require.Equal(t, "mytopic", reservations[0].Topic) + require.True(t, reservations[0].Owner.IsReadWrite()) + require.True(t, reservations[0].Everyone.IsRead()) + require.False(t, reservations[0].Everyone.IsWrite()) + }) } -func testStoreAllGrants(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false)) - phil, err := store.User("phil") - require.Nil(t, err) - ben, err := store.User("ben") - require.Nil(t, err) +func TestStoreReservationsCount(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "phil", false)) + require.Nil(t, store.AllowAccess("phil", "topic2", true, true, "phil", false)) - require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) - require.Nil(t, store.AllowAccess("ben", "topic2", true, false, "", false)) - - grants, err := store.AllGrants() - require.Nil(t, err) - require.Contains(t, grants, phil.ID) - require.Contains(t, grants, ben.ID) + count, err := store.ReservationsCount("phil") + require.Nil(t, err) + require.Equal(t, int64(2), count) + }) } -func testStoreOtherAccessCount(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("ben", "mytopic", true, true, "ben", false)) +func TestStoreHasReservation(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) - count, err := store.OtherAccessCount("phil", "mytopic") - require.Nil(t, err) - require.Equal(t, 1, count) + has, err := store.HasReservation("phil", "mytopic") + require.Nil(t, err) + require.True(t, has) + + has, err = store.HasReservation("phil", "other") + require.Nil(t, err) + require.False(t, has) + }) +} + +func TestStoreReservationOwner(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) + + owner, err := store.ReservationOwner("mytopic") + require.Nil(t, err) + require.NotEmpty(t, owner) // Returns the user ID + + owner, err = store.ReservationOwner("unowned") + require.Nil(t, err) + require.Empty(t, owner) + }) +} + +func TestStoreTiers(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + MessageLimit: 5000, + MessageExpiryDuration: 24 * time.Hour, + EmailLimit: 100, + CallLimit: 10, + ReservationLimit: 20, + AttachmentFileSizeLimit: 10 * 1024 * 1024, + AttachmentTotalSizeLimit: 100 * 1024 * 1024, + AttachmentExpiryDuration: 48 * time.Hour, + AttachmentBandwidthLimit: 500 * 1024 * 1024, + } + require.Nil(t, store.AddTier(tier)) + + // Get by code + t2, err := store.Tier("pro") + require.Nil(t, err) + require.Equal(t, "ti_test", t2.ID) + require.Equal(t, "pro", t2.Code) + require.Equal(t, "Pro", t2.Name) + require.Equal(t, int64(5000), t2.MessageLimit) + require.Equal(t, int64(100), t2.EmailLimit) + require.Equal(t, int64(10), t2.CallLimit) + require.Equal(t, int64(20), t2.ReservationLimit) + + // List all tiers + tiers, err := store.Tiers() + require.Nil(t, err) + require.Len(t, tiers, 1) + require.Equal(t, "pro", tiers[0].Code) + }) +} + +func TestStoreTierUpdate(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, store.AddTier(tier)) + + tier.Name = "Professional" + tier.MessageLimit = 9999 + require.Nil(t, store.UpdateTier(tier)) + + t2, err := store.Tier("pro") + require.Nil(t, err) + require.Equal(t, "Professional", t2.Name) + require.Equal(t, int64(9999), t2.MessageLimit) + }) +} + +func TestStoreTierRemove(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, store.AddTier(tier)) + + t2, err := store.Tier("pro") + require.Nil(t, err) + require.Equal(t, "pro", t2.Code) + + require.Nil(t, store.RemoveTier("pro")) + _, err = store.Tier("pro") + require.Equal(t, user.ErrTierNotFound, err) + }) +} + +func TestStoreTierByStripePrice(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + StripeMonthlyPriceID: "price_monthly", + StripeYearlyPriceID: "price_yearly", + } + require.Nil(t, store.AddTier(tier)) + + t2, err := store.TierByStripePrice("price_monthly") + require.Nil(t, err) + require.Equal(t, "pro", t2.Code) + + t3, err := store.TierByStripePrice("price_yearly") + require.Nil(t, err) + require.Equal(t, "pro", t3.Code) + }) +} + +func TestStoreChangeTier(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + tier := &user.Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, store.AddTier(tier)) + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.ChangeTier("phil", "pro")) + + u, err := store.User("phil") + require.Nil(t, err) + require.NotNil(t, u.Tier) + require.Equal(t, "pro", u.Tier.Code) + }) +} + +func TestStorePhoneNumbers(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + require.Nil(t, store.AddPhoneNumber(u.ID, "+1234567890")) + require.Nil(t, store.AddPhoneNumber(u.ID, "+0987654321")) + + numbers, err := store.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Len(t, numbers, 2) + + require.Nil(t, store.RemovePhoneNumber(u.ID, "+1234567890")) + numbers, err = store.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Len(t, numbers, 1) + require.Equal(t, "+0987654321", numbers[0]) + }) +} + +func TestStoreChangeSettings(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + lang := "de" + prefs := &user.Prefs{Language: &lang} + require.Nil(t, store.ChangeSettings(u.ID, prefs)) + + u2, err := store.User("phil") + require.Nil(t, err) + require.NotNil(t, u2.Prefs) + require.Equal(t, "de", *u2.Prefs.Language) + }) +} + +func TestStoreChangeBilling(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + + billing := &user.Billing{ + StripeCustomerID: "cus_123", + StripeSubscriptionID: "sub_456", + } + require.Nil(t, store.ChangeBilling("phil", billing)) + + u, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, "cus_123", u.Billing.StripeCustomerID) + require.Equal(t, "sub_456", u.Billing.StripeSubscriptionID) + }) +} + +func TestStoreUpdateStats(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + stats := &user.Stats{Messages: 42, Emails: 3, Calls: 1} + require.Nil(t, store.UpdateStats(u.ID, stats)) + + u2, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, int64(42), u2.Stats.Messages) + require.Equal(t, int64(3), u2.Stats.Emails) + require.Equal(t, int64(1), u2.Stats.Calls) + }) +} + +func TestStoreResetStats(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + require.Nil(t, store.UpdateStats(u.ID, &user.Stats{Messages: 42, Emails: 3, Calls: 1})) + require.Nil(t, store.ResetStats()) + + u2, err := store.User("phil") + require.Nil(t, err) + require.Equal(t, int64(0), u2.Stats.Messages) + require.Equal(t, int64(0), u2.Stats.Emails) + require.Equal(t, int64(0), u2.Stats.Calls) + }) +} + +func TestStoreMarkUserRemoved(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + require.Nil(t, store.MarkUserRemoved(u.ID)) + + u2, err := store.User("phil") + require.Nil(t, err) + require.True(t, u2.Deleted) + }) +} + +func TestStoreRemoveDeletedUsers(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + u, err := store.User("phil") + require.Nil(t, err) + + require.Nil(t, store.MarkUserRemoved(u.ID)) + + // RemoveDeletedUsers only removes users past the hard-delete duration (7 days). + // Immediately after marking, the user should still exist. + require.Nil(t, store.RemoveDeletedUsers()) + u2, err := store.User("phil") + require.Nil(t, err) + require.True(t, u2.Deleted) + }) +} + +func TestStoreAllGrants(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false)) + phil, err := store.User("phil") + require.Nil(t, err) + ben, err := store.User("ben") + require.Nil(t, err) + + require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) + require.Nil(t, store.AllowAccess("ben", "topic2", true, false, "", false)) + + grants, err := store.AllGrants() + require.Nil(t, err) + require.Contains(t, grants, phil.ID) + require.Contains(t, grants, ben.ID) + }) +} + +func TestStoreOtherAccessCount(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, store user.Store) { + require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) + require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false)) + require.Nil(t, store.AllowAccess("ben", "mytopic", true, true, "ben", false)) + + count, err := store.OtherAccessCount("phil", "mytopic") + require.Nil(t, err) + require.Equal(t, 1, count) + }) } diff --git a/webpush/store_postgres_test.go b/webpush/store_postgres_test.go deleted file mode 100644 index 1b2d43d2..00000000 --- a/webpush/store_postgres_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package webpush_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - dbtest "heckel.io/ntfy/v2/db/test" - "heckel.io/ntfy/v2/webpush" -) - -func newTestPostgresStore(t *testing.T) webpush.Store { - testDB := dbtest.CreateTestPostgres(t) - store, err := webpush.NewPostgresStore(testDB) - require.Nil(t, err) - return store -} - -func TestPostgresStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) { - testStoreUpsertSubscriptionSubscriptionsForTopic(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) { - testStoreUpsertSubscriptionSubscriberIPLimitReached(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUpsertSubscriptionUpdateTopics(t *testing.T) { - testStoreUpsertSubscriptionUpdateTopics(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreUpsertSubscriptionUpdateFields(t *testing.T) { - testStoreUpsertSubscriptionUpdateFields(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreRemoveByUserIDMultiple(t *testing.T) { - testStoreRemoveByUserIDMultiple(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreRemoveByEndpoint(t *testing.T) { - testStoreRemoveByEndpoint(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreRemoveByUserID(t *testing.T) { - testStoreRemoveByUserID(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreRemoveByUserIDEmpty(t *testing.T) { - testStoreRemoveByUserIDEmpty(t, newTestPostgresStore(t)) -} - -func TestPostgresStoreExpiryWarningSent(t *testing.T) { - store := newTestPostgresStore(t) - testStoreExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt) -} - -func TestPostgresStoreExpiring(t *testing.T) { - store := newTestPostgresStore(t) - testStoreExpiring(t, store, store.SetSubscriptionUpdatedAt) -} - -func TestPostgresStoreRemoveExpired(t *testing.T) { - store := newTestPostgresStore(t) - testStoreRemoveExpired(t, store, store.SetSubscriptionUpdatedAt) -} diff --git a/webpush/store_sqlite_test.go b/webpush/store_sqlite_test.go deleted file mode 100644 index 1d4087d1..00000000 --- a/webpush/store_sqlite_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package webpush_test - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/webpush" -) - -func newTestSQLiteStore(t *testing.T) webpush.Store { - store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") - require.Nil(t, err) - t.Cleanup(func() { store.Close() }) - return store -} - -func TestSQLiteStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) { - testStoreUpsertSubscriptionSubscriptionsForTopic(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) { - testStoreUpsertSubscriptionSubscriberIPLimitReached(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUpsertSubscriptionUpdateTopics(t *testing.T) { - testStoreUpsertSubscriptionUpdateTopics(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreUpsertSubscriptionUpdateFields(t *testing.T) { - testStoreUpsertSubscriptionUpdateFields(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreRemoveByUserIDMultiple(t *testing.T) { - testStoreRemoveByUserIDMultiple(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreRemoveByEndpoint(t *testing.T) { - testStoreRemoveByEndpoint(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreRemoveByUserID(t *testing.T) { - testStoreRemoveByUserID(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreRemoveByUserIDEmpty(t *testing.T) { - testStoreRemoveByUserIDEmpty(t, newTestSQLiteStore(t)) -} - -func TestSQLiteStoreExpiryWarningSent(t *testing.T) { - store := newTestSQLiteStore(t) - testStoreExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt) -} - -func TestSQLiteStoreExpiring(t *testing.T) { - store := newTestSQLiteStore(t) - testStoreExpiring(t, store, store.SetSubscriptionUpdatedAt) -} - -func TestSQLiteStoreRemoveExpired(t *testing.T) { - store := newTestSQLiteStore(t) - testStoreRemoveExpired(t, store, store.SetSubscriptionUpdatedAt) -} diff --git a/webpush/store_test.go b/webpush/store_test.go index fe2e28f3..bb27bf5f 100644 --- a/webpush/store_test.go +++ b/webpush/store_test.go @@ -3,211 +3,250 @@ package webpush_test import ( "fmt" "net/netip" + "path/filepath" "testing" "time" "github.com/stretchr/testify/require" + dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/webpush" ) const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" -func testStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T, store webpush.Store) { - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - subs, err := store.SubscriptionsForTopic("test-topic") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) - require.Equal(t, subs[0].P256dh, "p256dh-key") - require.Equal(t, subs[0].Auth, "auth-key") - require.Equal(t, subs[0].UserID, "u_1234") - - subs2, err := store.SubscriptionsForTopic("mytopic") - require.Nil(t, err) - require.Len(t, subs2, 1) - require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) +func forEachBackend(t *testing.T, f func(t *testing.T, store webpush.Store)) { + t.Run("sqlite", func(t *testing.T) { + store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") + require.Nil(t, err) + t.Cleanup(func() { store.Close() }) + f(t, store) + }) + t.Run("postgres", func(t *testing.T) { + testDB := dbtest.CreateTestPostgres(t) + store, err := webpush.NewPostgresStore(testDB) + require.Nil(t, err) + f(t, store) + }) } -func testStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T, store webpush.Store) { - // Insert 10 subscriptions with the same IP address - for i := 0; i < 10; i++ { - endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) - require.Nil(t, store.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - } +func TestStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - // Another one for the same endpoint should be fine - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + subs, err := store.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, subs[0].Endpoint, testWebPushEndpoint) + require.Equal(t, subs[0].P256dh, "p256dh-key") + require.Equal(t, subs[0].Auth, "auth-key") + require.Equal(t, subs[0].UserID, "u_1234") - // But with a different endpoint it should fail - require.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - - // But with a different IP address it should be fine again - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) + subs2, err := store.SubscriptionsForTopic("mytopic") + require.Nil(t, err) + require.Len(t, subs2, 1) + require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint) + }) } -func testStoreUpsertSubscriptionUpdateTopics(t *testing.T, store webpush.Store) { - // Insert subscription with two topics, and another with one topic - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) +func TestStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert 10 subscriptions with the same IP address + for i := 0; i < 10; i++ { + endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) + require.Nil(t, store.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) + } - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 2) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) + // Another one for the same endpoint should be fine + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - subs, err = store.SubscriptionsForTopic("topic2") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + // But with a different endpoint it should fail + require.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) - // Update the first subscription to have only one topic - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) - - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 2) - require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - - subs, err = store.SubscriptionsForTopic("topic2") - require.Nil(t, err) - require.Len(t, subs, 0) + // But with a different IP address it should be fine again + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"})) + }) } -func testStoreUpsertSubscriptionUpdateFields(t *testing.T, store webpush.Store) { - // Insert a subscription - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) +func TestStoreUpsertSubscriptionUpdateTopics(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert subscription with two topics, and another with one topic + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, "auth-key", subs[0].Auth) - require.Equal(t, "p256dh-key", subs[0].P256dh) - require.Equal(t, "u_1234", subs[0].UserID) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint) - // Re-upsert the same endpoint with different auth, p256dh, and userID - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "new-auth", "new-p256dh", "u_5678", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + subs, err = store.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) - require.Equal(t, "new-auth", subs[0].Auth) - require.Equal(t, "new-p256dh", subs[0].P256dh) - require.Equal(t, "u_5678", subs[0].UserID) + // Update the first subscription to have only one topic + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 2) + require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint) + + subs, err = store.SubscriptionsForTopic("topic2") + require.Nil(t, err) + require.Len(t, subs, 0) + }) } -func testStoreRemoveByUserIDMultiple(t *testing.T, store webpush.Store) { - // Insert two subscriptions for u_1234 and one for u_5678 - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"2", "auth-key", "p256dh-key", "u_5678", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) +func TestStoreUpsertSubscriptionUpdateFields(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert a subscription + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 3) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, "auth-key", subs[0].Auth) + require.Equal(t, "p256dh-key", subs[0].P256dh) + require.Equal(t, "u_1234", subs[0].UserID) - // Remove all subscriptions for u_1234 - require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) + // Re-upsert the same endpoint with different auth, p256dh, and userID + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "new-auth", "new-p256dh", "u_5678", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) - // Only u_5678's subscription should remain - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint+"2", subs[0].Endpoint) - require.Equal(t, "u_5678", subs[0].UserID) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) + require.Equal(t, "new-auth", subs[0].Auth) + require.Equal(t, "new-p256dh", subs[0].P256dh) + require.Equal(t, "u_5678", subs[0].UserID) + }) } -func testStoreRemoveByEndpoint(t *testing.T, store webpush.Store) { - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) +func TestStoreRemoveByUserIDMultiple(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert two subscriptions for u_1234 and one for u_5678 + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"2", "auth-key", "p256dh-key", "u_5678", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) - // And remove it again - require.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 3) + + // Remove all subscriptions for u_1234 + require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) + + // Only u_5678's subscription should remain + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint+"2", subs[0].Endpoint) + require.Equal(t, "u_5678", subs[0].UserID) + }) } -func testStoreRemoveByUserID(t *testing.T, store webpush.Store) { - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) +func TestStoreRemoveByEndpoint(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) - // And remove it again - require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) + // And remove it again + require.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint)) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) + }) } -func testStoreRemoveByUserIDEmpty(t *testing.T, store webpush.Store) { - require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) +func TestStoreRemoveByUserID(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // And remove it again + require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234")) + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) + }) } -func testStoreExpiryWarningSent(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - - // Set updated_at to the past so it shows up as expiring - require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix())) - - // Verify subscription appears in expiring list (warned_at == 0) - subs, err := store.SubscriptionsExpiring(7 * 24 * time.Hour) - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) - - // Mark them as warning sent - require.Nil(t, store.MarkExpiryWarningSent(subs)) - - // Verify subscription no longer appears in expiring list (warned_at > 0) - subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) - require.Nil(t, err) - require.Len(t, subs, 0) +func TestStoreRemoveByUserIDEmpty(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) + }) } -func testStoreExpiring(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) +func TestStoreExpiryWarningSent(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - // Fake-mark them as soon-to-expire - require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix())) + // Set updated_at to the past so it shows up as expiring + require.Nil(t, store.SetSubscriptionUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix())) - // Should not be cleaned up yet - require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + // Verify subscription appears in expiring list (warned_at == 0) + subs, err := store.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) - // Run expiration - subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) - require.Nil(t, err) - require.Len(t, subs, 1) - require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) + // Mark them as warning sent + require.Nil(t, store.MarkExpiryWarningSent(subs)) + + // Verify subscription no longer appears in expiring list (warned_at > 0) + subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 0) + }) } -func testStoreRemoveExpired(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) { - // Insert subscription with two topics - require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) - subs, err := store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 1) +func TestStoreExpiring(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) - // Fake-mark them as expired - require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-10*24*time.Hour).Unix())) + // Fake-mark them as soon-to-expire + require.Nil(t, store.SetSubscriptionUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix())) - // Run expiration - require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + // Should not be cleaned up yet + require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) - // List again, should be 0 - subs, err = store.SubscriptionsForTopic("topic1") - require.Nil(t, err) - require.Len(t, subs, 0) + // Run expiration + subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour) + require.Nil(t, err) + require.Len(t, subs, 1) + require.Equal(t, testWebPushEndpoint, subs[0].Endpoint) + }) +} + +func TestStoreRemoveExpired(t *testing.T) { + forEachBackend(t, func(t *testing.T, store webpush.Store) { + // Insert subscription with two topics + require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) + subs, err := store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 1) + + // Fake-mark them as expired + require.Nil(t, store.SetSubscriptionUpdatedAt(testWebPushEndpoint, time.Now().Add(-10*24*time.Hour).Unix())) + + // Run expiration + require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour)) + + // List again, should be 0 + subs, err = store.SubscriptionsForTopic("topic1") + require.Nil(t, err) + require.Len(t, subs, 0) + }) } From 6375c2ce60f505ae7eff9f09b0f15436bcca2813 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Feb 2026 21:29:29 -0500 Subject: [PATCH 220/548] Renaming for consistency --- message/store_postgres.go | 100 +++++++------- message/store_postgres_schema.go | 14 +- message/store_sqlite.go | 8 +- message/store_sqlite_schema.go | 34 ++--- user/store_postgres.go | 220 +++++++++++++++---------------- user/store_postgres_schema.go | 10 +- user/store_sqlite.go | 220 +++++++++++++++---------------- user/store_sqlite_schema.go | 28 ++-- webpush/store_postgres.go | 66 +++++----- webpush/store_sqlite.go | 22 ++-- 10 files changed, 361 insertions(+), 361 deletions(-) diff --git a/message/store_postgres.go b/message/store_postgres.go index 32150e2d..4fb052c8 100644 --- a/message/store_postgres.go +++ b/message/store_postgres.go @@ -7,99 +7,99 @@ import ( // PostgreSQL runtime query constants const ( - pgInsertMessageQuery = ` + postgresInsertMessageQuery = ` INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) ` - pgDeleteMessageQuery = `DELETE FROM message WHERE mid = $1` - pgSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` - pgDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` - pgUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2` - pgSelectRowIDFromMessageID = `SELECT id FROM message WHERE mid = $1` - pgSelectMessagesByIDQuery = ` + postgresDeleteMessageQuery = `DELETE FROM message WHERE mid = $1` + postgresSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` + postgresDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` + postgresUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2` + postgresSelectRowIDFromMessageIDQuery = `SELECT id FROM message WHERE mid = $1` + postgresSelectMessagesByIDQuery = ` 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 mid = $1 ` - pgSelectMessagesSinceTimeQuery = ` + postgresSelectMessagesSinceTimeQuery = ` 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 topic = $1 AND time >= $2 AND published = TRUE ORDER BY time, id ` - pgSelectMessagesSinceTimeIncludeScheduledQuery = ` + postgresSelectMessagesSinceTimeIncludeScheduledQuery = ` 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 topic = $1 AND time >= $2 ORDER BY time, id ` - pgSelectMessagesSinceIDQuery = ` + postgresSelectMessagesSinceIDQuery = ` 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 topic = $1 AND id > $2 AND published = TRUE ORDER BY time, id ` - pgSelectMessagesSinceIDIncludeScheduledQuery = ` + postgresSelectMessagesSinceIDIncludeScheduledQuery = ` 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 topic = $1 AND (id > $2 OR published = FALSE) ORDER BY time, id ` - pgSelectMessagesLatestQuery = ` + postgresSelectMessagesLatestQuery = ` 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 topic = $1 AND published = TRUE ORDER BY time DESC, id DESC LIMIT 1 ` - pgSelectMessagesDueQuery = ` + postgresSelectMessagesDueQuery = ` 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 ` - pgSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE` - pgUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1` - pgSelectMessagesCountQuery = `SELECT COUNT(*) FROM message` - pgSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM message GROUP BY topic` - pgSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic` + postgresSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE` + postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1` + postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message` + postgresSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM message GROUP BY topic` + postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic` - pgUpdateAttachmentDeleted = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1` - pgSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE` - pgSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2` - pgSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2` + postgresUpdateAttachmentDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1` + postgresSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE` + postgresSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2` + postgresSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2` - pgSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` - pgUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` - pgUpdateMessageTimesQuery = `UPDATE message SET time = $1 WHERE mid = $2` + postgresSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` + postgresUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` + postgresUpdateMessageTimesQuery = `UPDATE message SET time = $1 WHERE mid = $2` ) var pgQueries = storeQueries{ - insertMessage: pgInsertMessageQuery, - deleteMessage: pgDeleteMessageQuery, - selectScheduledMessageIDsBySeqID: pgSelectScheduledMessageIDsBySeqIDQuery, - deleteScheduledBySequenceID: pgDeleteScheduledBySequenceIDQuery, - updateMessagesForTopicExpiry: pgUpdateMessagesForTopicExpiryQuery, - selectRowIDFromMessageID: pgSelectRowIDFromMessageID, - selectMessagesByID: pgSelectMessagesByIDQuery, - selectMessagesSinceTime: pgSelectMessagesSinceTimeQuery, - selectMessagesSinceTimeScheduled: pgSelectMessagesSinceTimeIncludeScheduledQuery, - selectMessagesSinceID: pgSelectMessagesSinceIDQuery, - selectMessagesSinceIDScheduled: pgSelectMessagesSinceIDIncludeScheduledQuery, - selectMessagesLatest: pgSelectMessagesLatestQuery, - selectMessagesDue: pgSelectMessagesDueQuery, - selectMessagesExpired: pgSelectMessagesExpiredQuery, - updateMessagePublished: pgUpdateMessagePublishedQuery, - selectMessagesCount: pgSelectMessagesCountQuery, - selectMessageCountPerTopic: pgSelectMessageCountPerTopicQuery, - selectTopics: pgSelectTopicsQuery, - updateAttachmentDeleted: pgUpdateAttachmentDeleted, - selectAttachmentsExpired: pgSelectAttachmentsExpiredQuery, - selectAttachmentsSizeBySender: pgSelectAttachmentsSizeBySenderQuery, - selectAttachmentsSizeByUserID: pgSelectAttachmentsSizeByUserIDQuery, - selectStats: pgSelectStatsQuery, - updateStats: pgUpdateStatsQuery, - updateMessageTime: pgUpdateMessageTimesQuery, + insertMessage: postgresInsertMessageQuery, + deleteMessage: postgresDeleteMessageQuery, + selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery, + deleteScheduledBySequenceID: postgresDeleteScheduledBySequenceIDQuery, + updateMessagesForTopicExpiry: postgresUpdateMessagesForTopicExpiryQuery, + selectRowIDFromMessageID: postgresSelectRowIDFromMessageIDQuery, + selectMessagesByID: postgresSelectMessagesByIDQuery, + selectMessagesSinceTime: postgresSelectMessagesSinceTimeQuery, + selectMessagesSinceTimeScheduled: postgresSelectMessagesSinceTimeIncludeScheduledQuery, + selectMessagesSinceID: postgresSelectMessagesSinceIDQuery, + selectMessagesSinceIDScheduled: postgresSelectMessagesSinceIDIncludeScheduledQuery, + selectMessagesLatest: postgresSelectMessagesLatestQuery, + selectMessagesDue: postgresSelectMessagesDueQuery, + selectMessagesExpired: postgresSelectMessagesExpiredQuery, + updateMessagePublished: postgresUpdateMessagePublishedQuery, + selectMessagesCount: postgresSelectMessagesCountQuery, + selectMessageCountPerTopic: postgresSelectMessageCountPerTopicQuery, + selectTopics: postgresSelectTopicsQuery, + updateAttachmentDeleted: postgresUpdateAttachmentDeletedQuery, + selectAttachmentsExpired: postgresSelectAttachmentsExpiredQuery, + selectAttachmentsSizeBySender: postgresSelectAttachmentsSizeBySenderQuery, + selectAttachmentsSizeByUserID: postgresSelectAttachmentsSizeByUserIDQuery, + selectStats: postgresSelectStatsQuery, + updateStats: postgresUpdateStatsQuery, + updateMessageTime: postgresUpdateMessageTimesQuery, } // NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. diff --git a/message/store_postgres_schema.go b/message/store_postgres_schema.go index e7aa3fe8..6e4d654c 100644 --- a/message/store_postgres_schema.go +++ b/message/store_postgres_schema.go @@ -7,7 +7,7 @@ import ( // Initial PostgreSQL schema const ( - pgCreateTablesQuery = ` + postgresCreateTablesQuery = ` CREATE TABLE IF NOT EXISTS message ( id BIGSERIAL PRIMARY KEY, mid TEXT NOT NULL, @@ -57,14 +57,14 @@ const ( // PostgreSQL schema management queries const ( - pgCurrentSchemaVersion = 14 - pgInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('message', $1)` - pgSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'` + pgCurrentSchemaVersion = 14 + postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)` + postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'` ) func setupPostgresDB(db *sql.DB) error { var schemaVersion int - err := db.QueryRow(pgSelectSchemaVersionQuery).Scan(&schemaVersion) + err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { return setupNewPostgresDB(db) } @@ -80,10 +80,10 @@ func setupNewPostgresDB(db *sql.DB) error { return err } defer tx.Rollback() - if _, err := tx.Exec(pgCreateTablesQuery); err != nil { + if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { return err } - if _, err := tx.Exec(pgInsertSchemaVersion, pgCurrentSchemaVersion); err != nil { + if _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil { return err } return tx.Commit() diff --git a/message/store_sqlite.go b/message/store_sqlite.go index ebccd468..646e3975 100644 --- a/message/store_sqlite.go +++ b/message/store_sqlite.go @@ -20,7 +20,7 @@ const ( sqliteSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` sqliteDeleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` sqliteUpdateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` - sqliteSelectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` + sqliteSelectRowIDFromMessageIDQuery = `SELECT id FROM messages WHERE mid = ?` sqliteSelectMessagesByIDQuery = ` 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, content_type, encoding FROM messages @@ -69,7 +69,7 @@ const ( sqliteSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` - sqliteUpdateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` + sqliteUpdateAttachmentDeletedQuery = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` sqliteSelectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` sqliteSelectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?` sqliteSelectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?` @@ -85,7 +85,7 @@ var sqliteQueries = storeQueries{ selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery, deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery, updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery, - selectRowIDFromMessageID: sqliteSelectRowIDFromMessageID, + selectRowIDFromMessageID: sqliteSelectRowIDFromMessageIDQuery, selectMessagesByID: sqliteSelectMessagesByIDQuery, selectMessagesSinceTime: sqliteSelectMessagesSinceTimeQuery, selectMessagesSinceTimeScheduled: sqliteSelectMessagesSinceTimeIncludeScheduledQuery, @@ -98,7 +98,7 @@ var sqliteQueries = storeQueries{ selectMessagesCount: sqliteSelectMessagesCountQuery, selectMessageCountPerTopic: sqliteSelectMessageCountPerTopicQuery, selectTopics: sqliteSelectTopicsQuery, - updateAttachmentDeleted: sqliteUpdateAttachmentDeleted, + updateAttachmentDeleted: sqliteUpdateAttachmentDeletedQuery, selectAttachmentsExpired: sqliteSelectAttachmentsExpiredQuery, selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery, selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery, diff --git a/message/store_sqlite_schema.go b/message/store_sqlite_schema.go index cd01555f..8adc80f4 100644 --- a/message/store_sqlite_schema.go +++ b/message/store_sqlite_schema.go @@ -65,8 +65,8 @@ const ( version INT NOT NULL ); ` - sqliteInsertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - sqliteUpdateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` + sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` + sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1` sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` ) @@ -265,7 +265,7 @@ func setupNewSQLite(db *sql.DB) error { if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteInsertSchemaVersion, sqliteCurrentSchemaVersion); err != nil { + if _, err := db.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil { return err } return nil @@ -288,7 +288,7 @@ func sqliteMigrateFrom0(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteInsertSchemaVersion, 1); err != nil { + if _, err := db.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil { return err } return nil @@ -299,7 +299,7 @@ func sqliteMigrateFrom1(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteUpdateSchemaVersion, 2); err != nil { + if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil { return err } return nil @@ -310,7 +310,7 @@ func sqliteMigrateFrom2(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteUpdateSchemaVersion, 3); err != nil { + if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil { return err } return nil @@ -321,7 +321,7 @@ func sqliteMigrateFrom3(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteUpdateSchemaVersion, 4); err != nil { + if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil { return err } return nil @@ -332,7 +332,7 @@ func sqliteMigrateFrom4(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteUpdateSchemaVersion, 5); err != nil { + if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil { return err } return nil @@ -343,7 +343,7 @@ func sqliteMigrateFrom5(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteUpdateSchemaVersion, 6); err != nil { + if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil { return err } return nil @@ -354,7 +354,7 @@ func sqliteMigrateFrom6(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteUpdateSchemaVersion, 7); err != nil { + if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil { return err } return nil @@ -365,7 +365,7 @@ func sqliteMigrateFrom7(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteUpdateSchemaVersion, 8); err != nil { + if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil { return err } return nil @@ -376,7 +376,7 @@ func sqliteMigrateFrom8(db *sql.DB, _ time.Duration) error { if _, err := db.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteUpdateSchemaVersion, 9); err != nil { + if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil { return err } return nil @@ -395,7 +395,7 @@ func sqliteMigrateFrom9(db *sql.DB, cacheDuration time.Duration) error { if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 10); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil { return err } return tx.Commit() @@ -411,7 +411,7 @@ func sqliteMigrateFrom10(db *sql.DB, _ time.Duration) error { if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 11); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil { return err } return tx.Commit() @@ -427,7 +427,7 @@ func sqliteMigrateFrom11(db *sql.DB, _ time.Duration) error { if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 12); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil { return err } return tx.Commit() @@ -443,7 +443,7 @@ func sqliteMigrateFrom12(db *sql.DB, _ time.Duration) error { if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 13); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil { return err } return tx.Commit() @@ -459,7 +459,7 @@ func sqliteMigrateFrom13(db *sql.DB, _ time.Duration) error { if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 14); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil { return err } return tx.Commit() diff --git a/user/store_postgres.go b/user/store_postgres.go index be7f998f..666fd4f4 100644 --- a/user/store_postgres.go +++ b/user/store_postgres.go @@ -7,32 +7,32 @@ import ( // PostgreSQL queries const ( // User queries - postgresSelectUserByID = ` + postgresSelectUserByIDQuery = ` SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM "user" u LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = $1 ` - postgresSelectUserByName = ` + postgresSelectUserByNameQuery = ` SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM "user" u LEFT JOIN tier t on t.id = u.tier_id WHERE user_name = $1 ` - postgresSelectUserByToken = ` + postgresSelectUserByTokenQuery = ` SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM "user" u JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = $1 AND (tk.expires = 0 OR tk.expires >= $2) ` - postgresSelectUserByStripeID = ` + postgresSelectUserByStripeIDQuery = ` SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM "user" u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = $1 ` - postgresSelectUsernames = ` + postgresSelectUsernamesQuery = ` SELECT user_name FROM "user" ORDER BY @@ -42,41 +42,41 @@ const ( ELSE 2 END, user_name ` - postgresSelectUserCount = `SELECT COUNT(*) FROM "user"` - postgresSelectUserIDFromUsername = `SELECT id FROM "user" WHERE user_name = $1` - postgresInsertUser = `INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created) VALUES ($1, $2, $3, $4, $5, $6, $7)` - postgresUpdateUserPass = `UPDATE "user" SET pass = $1 WHERE user_name = $2` - postgresUpdateUserRole = `UPDATE "user" SET role = $1 WHERE user_name = $2` - postgresUpdateUserProvisioned = `UPDATE "user" SET provisioned = $1 WHERE user_name = $2` - postgresUpdateUserPrefs = `UPDATE "user" SET prefs = $1 WHERE id = $2` - postgresUpdateUserStats = `UPDATE "user" SET stats_messages = $1, stats_emails = $2, stats_calls = $3 WHERE id = $4` - postgresUpdateUserStatsResetAll = `UPDATE "user" SET stats_messages = 0, stats_emails = 0, stats_calls = 0` - postgresUpdateUserTier = `UPDATE "user" SET tier_id = (SELECT id FROM tier WHERE code = $1) WHERE user_name = $2` - postgresUpdateUserDeleted = `UPDATE "user" SET deleted = $1 WHERE id = $2` - postgresDeleteUser = `DELETE FROM "user" WHERE user_name = $1` - postgresDeleteUserTier = `UPDATE "user" SET tier_id = null WHERE user_name = $1` - postgresDeleteUsersMarked = `DELETE FROM "user" WHERE deleted < $1` + postgresSelectUserCountQuery = `SELECT COUNT(*) FROM "user"` + postgresSelectUserIDFromUsernameQuery = `SELECT id FROM "user" WHERE user_name = $1` + postgresInsertUserQuery = `INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created) VALUES ($1, $2, $3, $4, $5, $6, $7)` + postgresUpdateUserPassQuery = `UPDATE "user" SET pass = $1 WHERE user_name = $2` + postgresUpdateUserRoleQuery = `UPDATE "user" SET role = $1 WHERE user_name = $2` + postgresUpdateUserProvisionedQuery = `UPDATE "user" SET provisioned = $1 WHERE user_name = $2` + postgresUpdateUserPrefsQuery = `UPDATE "user" SET prefs = $1 WHERE id = $2` + postgresUpdateUserStatsQuery = `UPDATE "user" SET stats_messages = $1, stats_emails = $2, stats_calls = $3 WHERE id = $4` + postgresUpdateUserStatsResetAllQuery = `UPDATE "user" SET stats_messages = 0, stats_emails = 0, stats_calls = 0` + postgresUpdateUserTierQuery = `UPDATE "user" SET tier_id = (SELECT id FROM tier WHERE code = $1) WHERE user_name = $2` + postgresUpdateUserDeletedQuery = `UPDATE "user" SET deleted = $1 WHERE id = $2` + postgresDeleteUserQuery = `DELETE FROM "user" WHERE user_name = $1` + postgresDeleteUserTierQuery = `UPDATE "user" SET tier_id = null WHERE user_name = $1` + postgresDeleteUsersMarkedQuery = `DELETE FROM "user" WHERE deleted < $1` // Access queries - postgresSelectTopicPerms = ` + postgresSelectTopicPermsQuery = ` SELECT read, write FROM user_access a JOIN "user" u ON u.id = a.user_id WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\' ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC ` - postgresSelectUserAllAccess = ` + postgresSelectUserAllAccessQuery = ` SELECT user_id, topic, read, write, provisioned FROM user_access ORDER BY LENGTH(topic) DESC, CASE WHEN write THEN 1 ELSE 0 END DESC, CASE WHEN read THEN 1 ELSE 0 END DESC, topic ` - postgresSelectUserAccess = ` + postgresSelectUserAccessQuery = ` SELECT topic, read, write, provisioned FROM user_access WHERE user_id = (SELECT id FROM "user" WHERE user_name = $1) ORDER BY LENGTH(topic) DESC, CASE WHEN write THEN 1 ELSE 0 END DESC, CASE WHEN read THEN 1 ELSE 0 END DESC, topic ` - postgresSelectUserReservations = ` + postgresSelectUserReservationsQuery = ` SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write FROM user_access a_user LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM "user" WHERE user_name = $1) @@ -84,32 +84,32 @@ const ( AND a_user.owner_user_id = (SELECT id FROM "user" WHERE user_name = $2) ORDER BY a_user.topic ` - postgresSelectUserReservationsCount = ` + postgresSelectUserReservationsCountQuery = ` SELECT COUNT(*) FROM user_access WHERE user_id = owner_user_id AND owner_user_id = (SELECT id FROM "user" WHERE user_name = $1) ` - postgresSelectUserReservationsOwner = ` + postgresSelectUserReservationsOwnerQuery = ` SELECT owner_user_id FROM user_access WHERE topic = $1 AND user_id = owner_user_id ` - postgresSelectUserHasReservation = ` + postgresSelectUserHasReservationQuery = ` SELECT COUNT(*) FROM user_access WHERE user_id = owner_user_id AND owner_user_id = (SELECT id FROM "user" WHERE user_name = $1) AND topic = $2 ` - postgresSelectOtherAccessCount = ` + postgresSelectOtherAccessCountQuery = ` SELECT COUNT(*) FROM user_access WHERE (topic = $1 OR $2 LIKE topic ESCAPE '\') AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM "user" WHERE user_name = $3)) ` - postgresUpsertUserAccess = ` + postgresUpsertUserAccessQuery = ` INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) VALUES ( (SELECT id FROM "user" WHERE user_name = $1), @@ -122,38 +122,38 @@ const ( ON CONFLICT (user_id, topic) DO UPDATE SET read=EXCLUDED.read, write=EXCLUDED.write, owner_user_id=EXCLUDED.owner_user_id, provisioned=EXCLUDED.provisioned ` - postgresDeleteUserAccess = ` + postgresDeleteUserAccessQuery = ` DELETE FROM user_access WHERE user_id = (SELECT id FROM "user" WHERE user_name = $1) OR owner_user_id = (SELECT id FROM "user" WHERE user_name = $2) ` - postgresDeleteUserAccessProvisioned = `DELETE FROM user_access WHERE provisioned = true` - postgresDeleteTopicAccess = ` + postgresDeleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = true` + postgresDeleteTopicAccessQuery = ` DELETE FROM user_access WHERE (user_id = (SELECT id FROM "user" WHERE user_name = $1) OR owner_user_id = (SELECT id FROM "user" WHERE user_name = $2)) AND topic = $3 ` - postgresDeleteAllAccess = `DELETE FROM user_access` + postgresDeleteAllAccessQuery = `DELETE FROM user_access` // Token queries - postgresSelectToken = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1 AND token = $2` - postgresSelectTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1` - postgresSelectTokenCount = `SELECT COUNT(*) FROM user_token WHERE user_id = $1` - postgresSelectAllProvisionedTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = true` - postgresUpsertToken = ` + postgresSelectTokenQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1 AND token = $2` + postgresSelectTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1` + postgresSelectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = $1` + postgresSelectAllProvisionedTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = true` + postgresUpsertTokenQuery = ` INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, token) DO UPDATE SET label = EXCLUDED.label, expires = EXCLUDED.expires, provisioned = EXCLUDED.provisioned ` - postgresUpdateTokenLabel = `UPDATE user_token SET label = $1 WHERE user_id = $2 AND token = $3` - postgresUpdateTokenExpiry = `UPDATE user_token SET expires = $1 WHERE user_id = $2 AND token = $3` - postgresUpdateTokenLastAccess = `UPDATE user_token SET last_access = $1, last_origin = $2 WHERE token = $3` - postgresDeleteToken = `DELETE FROM user_token WHERE user_id = $1 AND token = $2` - postgresDeleteProvisionedToken = `DELETE FROM user_token WHERE token = $1` - postgresDeleteAllToken = `DELETE FROM user_token WHERE user_id = $1` - postgresDeleteExpiredTokens = `DELETE FROM user_token WHERE expires > 0 AND expires < $1` - postgresDeleteExcessTokens = ` + postgresUpdateTokenLabelQuery = `UPDATE user_token SET label = $1 WHERE user_id = $2 AND token = $3` + postgresUpdateTokenExpiryQuery = `UPDATE user_token SET expires = $1 WHERE user_id = $2 AND token = $3` + postgresUpdateTokenLastAccessQuery = `UPDATE user_token SET last_access = $1, last_origin = $2 WHERE token = $3` + postgresDeleteTokenQuery = `DELETE FROM user_token WHERE user_id = $1 AND token = $2` + postgresDeleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = $1` + postgresDeleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = $1` + postgresDeleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < $1` + postgresDeleteExcessTokensQuery = ` DELETE FROM user_token WHERE user_id = $1 AND (user_id, token) NOT IN ( @@ -166,38 +166,38 @@ const ( ` // Tier queries - postgresInsertTier = ` + postgresInsertTierQuery = ` INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ` - postgresUpdateTier = ` + postgresUpdateTierQuery = ` UPDATE tier SET name = $1, messages_limit = $2, messages_expiry_duration = $3, emails_limit = $4, calls_limit = $5, reservations_limit = $6, attachment_file_size_limit = $7, attachment_total_size_limit = $8, attachment_expiry_duration = $9, attachment_bandwidth_limit = $10, stripe_monthly_price_id = $11, stripe_yearly_price_id = $12 WHERE code = $13 ` - postgresSelectTiers = ` + postgresSelectTiersQuery = ` SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier ` - postgresSelectTierByCode = ` + postgresSelectTierByCodeQuery = ` SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier WHERE code = $1 ` - postgresSelectTierByPriceID = ` + postgresSelectTierByPriceIDQuery = ` SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier WHERE (stripe_monthly_price_id = $1 OR stripe_yearly_price_id = $2) ` - postgresDeleteTier = `DELETE FROM tier WHERE code = $1` + postgresDeleteTierQuery = `DELETE FROM tier WHERE code = $1` // Phone queries - postgresSelectPhoneNumbers = `SELECT phone_number FROM user_phone WHERE user_id = $1` - postgresInsertPhoneNumber = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)` - postgresDeletePhoneNumber = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` + postgresSelectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = $1` + postgresInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)` + postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` // Billing queries - postgresUpdateBilling = ` + postgresUpdateBillingQuery = ` UPDATE "user" SET stripe_customer_id = $1, stripe_subscription_id = $2, stripe_subscription_status = $3, stripe_subscription_interval = $4, stripe_subscription_paid_until = $5, stripe_subscription_cancel_at = $6 WHERE user_name = $7 @@ -213,71 +213,71 @@ func NewPostgresStore(db *sql.DB) (Store, error) { db: db, queries: storeQueries{ // User queries - selectUserByID: postgresSelectUserByID, - selectUserByName: postgresSelectUserByName, - selectUserByToken: postgresSelectUserByToken, - selectUserByStripeID: postgresSelectUserByStripeID, - selectUsernames: postgresSelectUsernames, - selectUserCount: postgresSelectUserCount, - selectUserIDFromUsername: postgresSelectUserIDFromUsername, - insertUser: postgresInsertUser, - updateUserPass: postgresUpdateUserPass, - updateUserRole: postgresUpdateUserRole, - updateUserProvisioned: postgresUpdateUserProvisioned, - updateUserPrefs: postgresUpdateUserPrefs, - updateUserStats: postgresUpdateUserStats, - updateUserStatsResetAll: postgresUpdateUserStatsResetAll, - updateUserTier: postgresUpdateUserTier, - updateUserDeleted: postgresUpdateUserDeleted, - deleteUser: postgresDeleteUser, - deleteUserTier: postgresDeleteUserTier, - deleteUsersMarked: postgresDeleteUsersMarked, + selectUserByID: postgresSelectUserByIDQuery, + selectUserByName: postgresSelectUserByNameQuery, + selectUserByToken: postgresSelectUserByTokenQuery, + selectUserByStripeID: postgresSelectUserByStripeIDQuery, + selectUsernames: postgresSelectUsernamesQuery, + selectUserCount: postgresSelectUserCountQuery, + selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery, + insertUser: postgresInsertUserQuery, + updateUserPass: postgresUpdateUserPassQuery, + updateUserRole: postgresUpdateUserRoleQuery, + updateUserProvisioned: postgresUpdateUserProvisionedQuery, + updateUserPrefs: postgresUpdateUserPrefsQuery, + updateUserStats: postgresUpdateUserStatsQuery, + updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery, + updateUserTier: postgresUpdateUserTierQuery, + updateUserDeleted: postgresUpdateUserDeletedQuery, + deleteUser: postgresDeleteUserQuery, + deleteUserTier: postgresDeleteUserTierQuery, + deleteUsersMarked: postgresDeleteUsersMarkedQuery, // Access queries - selectTopicPerms: postgresSelectTopicPerms, - selectUserAllAccess: postgresSelectUserAllAccess, - selectUserAccess: postgresSelectUserAccess, - selectUserReservations: postgresSelectUserReservations, - selectUserReservationsCount: postgresSelectUserReservationsCount, - selectUserReservationsOwner: postgresSelectUserReservationsOwner, - selectUserHasReservation: postgresSelectUserHasReservation, - selectOtherAccessCount: postgresSelectOtherAccessCount, - upsertUserAccess: postgresUpsertUserAccess, - deleteUserAccess: postgresDeleteUserAccess, - deleteUserAccessProvisioned: postgresDeleteUserAccessProvisioned, - deleteTopicAccess: postgresDeleteTopicAccess, - deleteAllAccess: postgresDeleteAllAccess, + selectTopicPerms: postgresSelectTopicPermsQuery, + selectUserAllAccess: postgresSelectUserAllAccessQuery, + selectUserAccess: postgresSelectUserAccessQuery, + selectUserReservations: postgresSelectUserReservationsQuery, + selectUserReservationsCount: postgresSelectUserReservationsCountQuery, + selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery, + selectUserHasReservation: postgresSelectUserHasReservationQuery, + selectOtherAccessCount: postgresSelectOtherAccessCountQuery, + upsertUserAccess: postgresUpsertUserAccessQuery, + deleteUserAccess: postgresDeleteUserAccessQuery, + deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery, + deleteTopicAccess: postgresDeleteTopicAccessQuery, + deleteAllAccess: postgresDeleteAllAccessQuery, // Token queries - selectToken: postgresSelectToken, - selectTokens: postgresSelectTokens, - selectTokenCount: postgresSelectTokenCount, - selectAllProvisionedTokens: postgresSelectAllProvisionedTokens, - upsertToken: postgresUpsertToken, - updateTokenLabel: postgresUpdateTokenLabel, - updateTokenExpiry: postgresUpdateTokenExpiry, - updateTokenLastAccess: postgresUpdateTokenLastAccess, - deleteToken: postgresDeleteToken, - deleteProvisionedToken: postgresDeleteProvisionedToken, - deleteAllToken: postgresDeleteAllToken, - deleteExpiredTokens: postgresDeleteExpiredTokens, - deleteExcessTokens: postgresDeleteExcessTokens, + selectToken: postgresSelectTokenQuery, + selectTokens: postgresSelectTokensQuery, + selectTokenCount: postgresSelectTokenCountQuery, + selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, + upsertToken: postgresUpsertTokenQuery, + updateTokenLabel: postgresUpdateTokenLabelQuery, + updateTokenExpiry: postgresUpdateTokenExpiryQuery, + updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, + deleteToken: postgresDeleteTokenQuery, + deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, + deleteAllToken: postgresDeleteAllTokenQuery, + deleteExpiredTokens: postgresDeleteExpiredTokensQuery, + deleteExcessTokens: postgresDeleteExcessTokensQuery, // Tier queries - insertTier: postgresInsertTier, - selectTiers: postgresSelectTiers, - selectTierByCode: postgresSelectTierByCode, - selectTierByPriceID: postgresSelectTierByPriceID, - updateTier: postgresUpdateTier, - deleteTier: postgresDeleteTier, + insertTier: postgresInsertTierQuery, + selectTiers: postgresSelectTiersQuery, + selectTierByCode: postgresSelectTierByCodeQuery, + selectTierByPriceID: postgresSelectTierByPriceIDQuery, + updateTier: postgresUpdateTierQuery, + deleteTier: postgresDeleteTierQuery, // Phone queries - selectPhoneNumbers: postgresSelectPhoneNumbers, - insertPhoneNumber: postgresInsertPhoneNumber, - deletePhoneNumber: postgresDeletePhoneNumber, + selectPhoneNumbers: postgresSelectPhoneNumbersQuery, + insertPhoneNumber: postgresInsertPhoneNumberQuery, + deletePhoneNumber: postgresDeletePhoneNumberQuery, // Billing queries - updateBilling: postgresUpdateBilling, + updateBilling: postgresUpdateBillingQuery, }, }, nil } diff --git a/user/store_postgres_schema.go b/user/store_postgres_schema.go index c5824970..3684c279 100644 --- a/user/store_postgres_schema.go +++ b/user/store_postgres_schema.go @@ -84,14 +84,14 @@ const ( // Schema table management queries for Postgres const ( - postgresCurrentSchemaVersion = 6 - postgresSelectSchemaVersion = `SELECT version FROM schema_version WHERE store = 'user'` - postgresInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` + postgresCurrentSchemaVersion = 6 + postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'` + postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` ) func setupPostgres(db *sql.DB) error { var schemaVersion int - err := db.QueryRow(postgresSelectSchemaVersion).Scan(&schemaVersion) + err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { return setupNewPostgres(db) } @@ -106,7 +106,7 @@ func setupNewPostgres(db *sql.DB) error { if _, err := db.Exec(postgresCreateTablesQueries); err != nil { return err } - if _, err := db.Exec(postgresInsertSchemaVersion, postgresCurrentSchemaVersion); err != nil { + if _, err := db.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil { return err } return nil diff --git a/user/store_sqlite.go b/user/store_sqlite.go index fc5bc7eb..6325e467 100644 --- a/user/store_sqlite.go +++ b/user/store_sqlite.go @@ -8,32 +8,32 @@ import ( const ( // User queries - sqliteSelectUserByID = ` + sqliteSelectUserByIDQuery = ` SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = ? ` - sqliteSelectUserByName = ` + sqliteSelectUserByNameQuery = ` SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` - sqliteSelectUserByToken = ` + sqliteSelectUserByTokenQuery = ` SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` - sqliteSelectUserByStripeID = ` + sqliteSelectUserByStripeIDQuery = ` SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? ` - sqliteSelectUsernames = ` + sqliteSelectUsernamesQuery = ` SELECT user FROM user ORDER BY @@ -43,41 +43,41 @@ const ( ELSE 2 END, user ` - sqliteSelectUserCount = `SELECT COUNT(*) FROM user` - sqliteSelectUserIDFromUsername = `SELECT id FROM user WHERE user = ?` - sqliteInsertUser = `INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) VALUES (?, ?, ?, ?, ?, ?, ?)` - sqliteUpdateUserPass = `UPDATE user SET pass = ? WHERE user = ?` - sqliteUpdateUserRole = `UPDATE user SET role = ? WHERE user = ?` - sqliteUpdateUserProvisioned = `UPDATE user SET provisioned = ? WHERE user = ?` - sqliteUpdateUserPrefs = `UPDATE user SET prefs = ? WHERE id = ?` - sqliteUpdateUserStats = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` - sqliteUpdateUserStatsResetAll = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` - sqliteUpdateUserTier = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?` - sqliteUpdateUserDeleted = `UPDATE user SET deleted = ? WHERE id = ?` - sqliteDeleteUser = `DELETE FROM user WHERE user = ?` - sqliteDeleteUserTier = `UPDATE user SET tier_id = null WHERE user = ?` - sqliteDeleteUsersMarked = `DELETE FROM user WHERE deleted < ?` + sqliteSelectUserCountQuery = `SELECT COUNT(*) FROM user` + sqliteSelectUserIDFromUsernameQuery = `SELECT id FROM user WHERE user = ?` + sqliteInsertUserQuery = `INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) VALUES (?, ?, ?, ?, ?, ?, ?)` + sqliteUpdateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` + sqliteUpdateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` + sqliteUpdateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?` + sqliteUpdateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` + sqliteUpdateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` + sqliteUpdateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` + sqliteUpdateUserTierQuery = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?` + sqliteUpdateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` + sqliteDeleteUserQuery = `DELETE FROM user WHERE user = ?` + sqliteDeleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?` + sqliteDeleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` // Access queries - sqliteSelectTopicPerms = ` + sqliteSelectTopicPermsQuery = ` SELECT read, write FROM user_access a JOIN user u ON u.id = a.user_id WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\' ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC ` - sqliteSelectUserAllAccess = ` + sqliteSelectUserAllAccessQuery = ` SELECT user_id, topic, read, write, provisioned FROM user_access ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic ` - sqliteSelectUserAccess = ` + sqliteSelectUserAccessQuery = ` SELECT topic, read, write, provisioned FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic ` - sqliteSelectUserReservations = ` + sqliteSelectUserReservationsQuery = ` SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write FROM user_access a_user LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?) @@ -85,69 +85,69 @@ const ( AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?) ORDER BY a_user.topic ` - sqliteSelectUserReservationsCount = ` + sqliteSelectUserReservationsCountQuery = ` SELECT COUNT(*) FROM user_access WHERE user_id = owner_user_id AND owner_user_id = (SELECT id FROM user WHERE user = ?) ` - sqliteSelectUserReservationsOwner = ` + sqliteSelectUserReservationsOwnerQuery = ` SELECT owner_user_id FROM user_access WHERE topic = ? AND user_id = owner_user_id ` - sqliteSelectUserHasReservation = ` + sqliteSelectUserHasReservationQuery = ` SELECT COUNT(*) FROM user_access WHERE user_id = owner_user_id AND owner_user_id = (SELECT id FROM user WHERE user = ?) AND topic = ? ` - sqliteSelectOtherAccessCount = ` + sqliteSelectOtherAccessCountQuery = ` SELECT COUNT(*) FROM user_access WHERE (topic = ? OR ? LIKE topic ESCAPE '\') AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) ` - sqliteUpsertUserAccess = ` + sqliteUpsertUserAccessQuery = ` INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?) ON CONFLICT (user_id, topic) DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned ` - sqliteDeleteUserAccess = ` + sqliteDeleteUserAccessQuery = ` DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?) ` - sqliteDeleteUserAccessProvisioned = `DELETE FROM user_access WHERE provisioned = 1` - sqliteDeleteTopicAccess = ` + sqliteDeleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = 1` + sqliteDeleteTopicAccessQuery = ` DELETE FROM user_access WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)) AND topic = ? ` - sqliteDeleteAllAccess = `DELETE FROM user_access` + sqliteDeleteAllAccessQuery = `DELETE FROM user_access` // Token queries - sqliteSelectToken = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?` - sqliteSelectTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ?` - sqliteSelectTokenCount = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` - sqliteSelectAllProvisionedTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1` - sqliteUpsertToken = ` + sqliteSelectTokenQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?` + sqliteSelectTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ?` + sqliteSelectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` + sqliteSelectAllProvisionedTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1` + sqliteUpsertTokenQuery = ` INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, token) DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned; ` - sqliteUpdateTokenLabel = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` - sqliteUpdateTokenExpiry = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` - sqliteUpdateTokenLastAccess = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` - sqliteDeleteToken = `DELETE FROM user_token WHERE user_id = ? AND token = ?` - sqliteDeleteProvisionedToken = `DELETE FROM user_token WHERE token = ?` - sqliteDeleteAllToken = `DELETE FROM user_token WHERE user_id = ?` - sqliteDeleteExpiredTokens = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` - sqliteDeleteExcessTokens = ` + sqliteUpdateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` + sqliteUpdateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` + sqliteUpdateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` + sqliteDeleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` + sqliteDeleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = ?` + sqliteDeleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` + sqliteDeleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` + sqliteDeleteExcessTokensQuery = ` DELETE FROM user_token WHERE user_id = ? AND (user_id, token) NOT IN ( @@ -160,38 +160,38 @@ const ( ` // Tier queries - sqliteInsertTier = ` + sqliteInsertTierQuery = ` INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` - sqliteUpdateTier = ` + sqliteUpdateTierQuery = ` UPDATE tier SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? WHERE code = ? ` - sqliteSelectTiers = ` + sqliteSelectTiersQuery = ` SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier ` - sqliteSelectTierByCode = ` + sqliteSelectTierByCodeQuery = ` SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier WHERE code = ? ` - sqliteSelectTierByPriceID = ` + sqliteSelectTierByPriceIDQuery = ` SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) ` - sqliteDeleteTier = `DELETE FROM tier WHERE code = ?` + sqliteDeleteTierQuery = `DELETE FROM tier WHERE code = ?` // Phone queries - sqliteSelectPhoneNumbers = `SELECT phone_number FROM user_phone WHERE user_id = ?` - sqliteInsertPhoneNumber = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` - sqliteDeletePhoneNumber = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` + sqliteSelectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?` + sqliteInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` + sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` // Billing queries - sqliteUpdateBilling = ` + sqliteUpdateBillingQuery = ` UPDATE user SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ? WHERE user = ? @@ -213,61 +213,61 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { return &commonStore{ db: db, queries: storeQueries{ - selectUserByID: sqliteSelectUserByID, - selectUserByName: sqliteSelectUserByName, - selectUserByToken: sqliteSelectUserByToken, - selectUserByStripeID: sqliteSelectUserByStripeID, - selectUsernames: sqliteSelectUsernames, - selectUserCount: sqliteSelectUserCount, - selectUserIDFromUsername: sqliteSelectUserIDFromUsername, - insertUser: sqliteInsertUser, - updateUserPass: sqliteUpdateUserPass, - updateUserRole: sqliteUpdateUserRole, - updateUserProvisioned: sqliteUpdateUserProvisioned, - updateUserPrefs: sqliteUpdateUserPrefs, - updateUserStats: sqliteUpdateUserStats, - updateUserStatsResetAll: sqliteUpdateUserStatsResetAll, - updateUserTier: sqliteUpdateUserTier, - updateUserDeleted: sqliteUpdateUserDeleted, - deleteUser: sqliteDeleteUser, - deleteUserTier: sqliteDeleteUserTier, - deleteUsersMarked: sqliteDeleteUsersMarked, - selectTopicPerms: sqliteSelectTopicPerms, - selectUserAllAccess: sqliteSelectUserAllAccess, - selectUserAccess: sqliteSelectUserAccess, - selectUserReservations: sqliteSelectUserReservations, - selectUserReservationsCount: sqliteSelectUserReservationsCount, - selectUserReservationsOwner: sqliteSelectUserReservationsOwner, - selectUserHasReservation: sqliteSelectUserHasReservation, - selectOtherAccessCount: sqliteSelectOtherAccessCount, - upsertUserAccess: sqliteUpsertUserAccess, - deleteUserAccess: sqliteDeleteUserAccess, - deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisioned, - deleteTopicAccess: sqliteDeleteTopicAccess, - deleteAllAccess: sqliteDeleteAllAccess, - selectToken: sqliteSelectToken, - selectTokens: sqliteSelectTokens, - selectTokenCount: sqliteSelectTokenCount, - selectAllProvisionedTokens: sqliteSelectAllProvisionedTokens, - upsertToken: sqliteUpsertToken, - updateTokenLabel: sqliteUpdateTokenLabel, - updateTokenExpiry: sqliteUpdateTokenExpiry, - updateTokenLastAccess: sqliteUpdateTokenLastAccess, - deleteToken: sqliteDeleteToken, - deleteProvisionedToken: sqliteDeleteProvisionedToken, - deleteAllToken: sqliteDeleteAllToken, - deleteExpiredTokens: sqliteDeleteExpiredTokens, - deleteExcessTokens: sqliteDeleteExcessTokens, - insertTier: sqliteInsertTier, - selectTiers: sqliteSelectTiers, - selectTierByCode: sqliteSelectTierByCode, - selectTierByPriceID: sqliteSelectTierByPriceID, - updateTier: sqliteUpdateTier, - deleteTier: sqliteDeleteTier, - selectPhoneNumbers: sqliteSelectPhoneNumbers, - insertPhoneNumber: sqliteInsertPhoneNumber, - deletePhoneNumber: sqliteDeletePhoneNumber, - updateBilling: sqliteUpdateBilling, + selectUserByID: sqliteSelectUserByIDQuery, + selectUserByName: sqliteSelectUserByNameQuery, + selectUserByToken: sqliteSelectUserByTokenQuery, + selectUserByStripeID: sqliteSelectUserByStripeIDQuery, + selectUsernames: sqliteSelectUsernamesQuery, + selectUserCount: sqliteSelectUserCountQuery, + selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery, + insertUser: sqliteInsertUserQuery, + updateUserPass: sqliteUpdateUserPassQuery, + updateUserRole: sqliteUpdateUserRoleQuery, + updateUserProvisioned: sqliteUpdateUserProvisionedQuery, + updateUserPrefs: sqliteUpdateUserPrefsQuery, + updateUserStats: sqliteUpdateUserStatsQuery, + updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery, + updateUserTier: sqliteUpdateUserTierQuery, + updateUserDeleted: sqliteUpdateUserDeletedQuery, + deleteUser: sqliteDeleteUserQuery, + deleteUserTier: sqliteDeleteUserTierQuery, + deleteUsersMarked: sqliteDeleteUsersMarkedQuery, + selectTopicPerms: sqliteSelectTopicPermsQuery, + selectUserAllAccess: sqliteSelectUserAllAccessQuery, + selectUserAccess: sqliteSelectUserAccessQuery, + selectUserReservations: sqliteSelectUserReservationsQuery, + selectUserReservationsCount: sqliteSelectUserReservationsCountQuery, + selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery, + selectUserHasReservation: sqliteSelectUserHasReservationQuery, + selectOtherAccessCount: sqliteSelectOtherAccessCountQuery, + upsertUserAccess: sqliteUpsertUserAccessQuery, + deleteUserAccess: sqliteDeleteUserAccessQuery, + deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery, + deleteTopicAccess: sqliteDeleteTopicAccessQuery, + deleteAllAccess: sqliteDeleteAllAccessQuery, + selectToken: sqliteSelectTokenQuery, + selectTokens: sqliteSelectTokensQuery, + selectTokenCount: sqliteSelectTokenCountQuery, + selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, + upsertToken: sqliteUpsertTokenQuery, + updateTokenLabel: sqliteUpdateTokenLabelQuery, + updateTokenExpiry: sqliteUpdateTokenExpiryQuery, + updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, + deleteToken: sqliteDeleteTokenQuery, + deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, + deleteAllToken: sqliteDeleteAllTokenQuery, + deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, + deleteExcessTokens: sqliteDeleteExcessTokensQuery, + insertTier: sqliteInsertTierQuery, + selectTiers: sqliteSelectTiersQuery, + selectTierByCode: sqliteSelectTierByCodeQuery, + selectTierByPriceID: sqliteSelectTierByPriceIDQuery, + updateTier: sqliteUpdateTierQuery, + deleteTier: sqliteDeleteTierQuery, + selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, + insertPhoneNumber: sqliteInsertPhoneNumberQuery, + deletePhoneNumber: sqliteDeletePhoneNumberQuery, + updateBilling: sqliteUpdateBillingQuery, }, }, nil } diff --git a/user/store_sqlite_schema.go b/user/store_sqlite_schema.go index f8e65cb6..4fbd0c02 100644 --- a/user/store_sqlite_schema.go +++ b/user/store_sqlite_schema.go @@ -103,8 +103,8 @@ const ( // Schema version table management for SQLite const ( sqliteCurrentSchemaVersion = 6 - sqliteInsertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - sqliteUpdateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` + sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` + sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1` sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` ) @@ -179,12 +179,12 @@ const ( VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH()) ON CONFLICT (id) DO NOTHING; ` - sqliteMigrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old` - sqliteMigrate1To2InsertUserNoTx = ` + sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery = `SELECT user FROM user_old` + sqliteMigrate1To2InsertUserNoTxQuery = ` INSERT INTO user (id, user, pass, role, sync_topic, created) SELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ? ` - sqliteMigrate1To2InsertFromOldTablesAndDropNoTx = ` + sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery = ` INSERT INTO user_access (user_id, topic, read, write) SELECT u.id, a.topic, a.read, a.write FROM user u @@ -352,7 +352,7 @@ func setupNewSQLite(db *sql.DB) error { if _, err := db.Exec(sqliteCreateTablesQueries); err != nil { return err } - if _, err := db.Exec(sqliteInsertSchemaVersion, sqliteCurrentSchemaVersion); err != nil { + if _, err := db.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil { return err } return nil @@ -382,7 +382,7 @@ func sqliteMigrateFrom1(db *sql.DB) error { return err } // Insert users from user_old into new user table, with ID and sync_topic - rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTx) + rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery) if err != nil { return err } @@ -401,15 +401,15 @@ func sqliteMigrateFrom1(db *sql.DB) error { for _, username := range usernames { userID := util.RandomStringPrefix(userIDPrefix, userIDLength) syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) - if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil { + 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(sqliteMigrate1To2InsertFromOldTablesAndDropNoTx); err != nil { + if _, err := tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 2); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil { return err } if err := tx.Commit(); err != nil { @@ -428,7 +428,7 @@ func sqliteMigrateFrom2(db *sql.DB) error { if _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 3); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil { return err } return tx.Commit() @@ -444,7 +444,7 @@ func sqliteMigrateFrom3(db *sql.DB) error { if _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 4); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil { return err } return tx.Commit() @@ -460,7 +460,7 @@ func sqliteMigrateFrom4(db *sql.DB) error { if _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 5); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil { return err } return tx.Commit() @@ -476,7 +476,7 @@ func sqliteMigrateFrom5(db *sql.DB) error { if _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil { return err } - if _, err := tx.Exec(sqliteUpdateSchemaVersion, 6); err != nil { + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil { return err } return tx.Commit() diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index c6366367..831b50f3 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -6,7 +6,7 @@ import ( ) const ( - pgCreateTablesQuery = ` + postgresCreateTablesQuery = ` CREATE TABLE IF NOT EXISTS webpush_subscription ( id TEXT PRIMARY KEY, endpoint TEXT NOT NULL UNIQUE, @@ -30,42 +30,42 @@ const ( ); ` - pgSelectSubscriptionIDByEndpoint = `SELECT id FROM webpush_subscription WHERE endpoint = $1` - pgSelectSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM webpush_subscription WHERE subscriber_ip = $1` - pgSelectSubscriptionsForTopicQuery = ` + postgresSelectSubscriptionIDByEndpointQuery = `SELECT id FROM webpush_subscription WHERE endpoint = $1` + postgresSelectSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM webpush_subscription WHERE subscriber_ip = $1` + postgresSelectSubscriptionsForTopicQuery = ` SELECT s.id, s.endpoint, s.key_auth, s.key_p256dh, s.user_id FROM webpush_subscription_topic st JOIN webpush_subscription s ON s.id = st.subscription_id WHERE st.topic = $1 ORDER BY s.endpoint ` - pgSelectSubscriptionsExpiringSoonQuery = ` + postgresSelectSubscriptionsExpiringSoonQuery = ` SELECT id, endpoint, key_auth, key_p256dh, user_id FROM webpush_subscription WHERE warned_at = 0 AND updated_at <= $1 ` - pgInsertSubscriptionQuery = ` + postgresInsertSubscriptionQuery = ` INSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (endpoint) DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at ` - pgUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2` - pgUpdateSubscriptionUpdatedAtQuery = `UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2` - pgDeleteSubscriptionByEndpointQuery = `DELETE FROM webpush_subscription WHERE endpoint = $1` - pgDeleteSubscriptionByUserIDQuery = `DELETE FROM webpush_subscription WHERE user_id = $1` - pgDeleteSubscriptionByAgeQuery = `DELETE FROM webpush_subscription WHERE updated_at <= $1` + postgresUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2` + postgresUpdateSubscriptionUpdatedAtQuery = `UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2` + postgresDeleteSubscriptionByEndpointQuery = `DELETE FROM webpush_subscription WHERE endpoint = $1` + postgresDeleteSubscriptionByUserIDQuery = `DELETE FROM webpush_subscription WHERE user_id = $1` + postgresDeleteSubscriptionByAgeQuery = `DELETE FROM webpush_subscription WHERE updated_at <= $1` - pgInsertSubscriptionTopicQuery = `INSERT INTO webpush_subscription_topic (subscription_id, topic) VALUES ($1, $2)` - pgDeleteSubscriptionTopicAllQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id = $1` - pgDeleteSubscriptionTopicWithoutSubscription = `DELETE FROM webpush_subscription_topic WHERE subscription_id NOT IN (SELECT id FROM webpush_subscription)` + postgresInsertSubscriptionTopicQuery = `INSERT INTO webpush_subscription_topic (subscription_id, topic) VALUES ($1, $2)` + postgresDeleteSubscriptionTopicAllQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id = $1` + postgresDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id NOT IN (SELECT id FROM webpush_subscription)` ) // PostgreSQL schema management queries const ( - pgCurrentSchemaVersion = 1 - pgInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('webpush', $1)` - pgSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'webpush'` + pgCurrentSchemaVersion = 1 + postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('webpush', $1)` + postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'webpush'` ) // NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool. @@ -76,26 +76,26 @@ func NewPostgresStore(db *sql.DB) (Store, error) { return &commonStore{ db: db, queries: storeQueries{ - selectSubscriptionIDByEndpoint: pgSelectSubscriptionIDByEndpoint, - selectSubscriptionCountBySubscriberIP: pgSelectSubscriptionCountBySubscriberIP, - selectSubscriptionsForTopic: pgSelectSubscriptionsForTopicQuery, - selectSubscriptionsExpiringSoon: pgSelectSubscriptionsExpiringSoonQuery, - insertSubscription: pgInsertSubscriptionQuery, - updateSubscriptionWarningSent: pgUpdateSubscriptionWarningSentQuery, - updateSubscriptionUpdatedAt: pgUpdateSubscriptionUpdatedAtQuery, - deleteSubscriptionByEndpoint: pgDeleteSubscriptionByEndpointQuery, - deleteSubscriptionByUserID: pgDeleteSubscriptionByUserIDQuery, - deleteSubscriptionByAge: pgDeleteSubscriptionByAgeQuery, - insertSubscriptionTopic: pgInsertSubscriptionTopicQuery, - deleteSubscriptionTopicAll: pgDeleteSubscriptionTopicAllQuery, - deleteSubscriptionTopicWithoutSubscription: pgDeleteSubscriptionTopicWithoutSubscription, + selectSubscriptionIDByEndpoint: postgresSelectSubscriptionIDByEndpointQuery, + selectSubscriptionCountBySubscriberIP: postgresSelectSubscriptionCountBySubscriberIPQuery, + selectSubscriptionsForTopic: postgresSelectSubscriptionsForTopicQuery, + selectSubscriptionsExpiringSoon: postgresSelectSubscriptionsExpiringSoonQuery, + insertSubscription: postgresInsertSubscriptionQuery, + updateSubscriptionWarningSent: postgresUpdateSubscriptionWarningSentQuery, + updateSubscriptionUpdatedAt: postgresUpdateSubscriptionUpdatedAtQuery, + deleteSubscriptionByEndpoint: postgresDeleteSubscriptionByEndpointQuery, + deleteSubscriptionByUserID: postgresDeleteSubscriptionByUserIDQuery, + deleteSubscriptionByAge: postgresDeleteSubscriptionByAgeQuery, + insertSubscriptionTopic: postgresInsertSubscriptionTopicQuery, + deleteSubscriptionTopicAll: postgresDeleteSubscriptionTopicAllQuery, + deleteSubscriptionTopicWithoutSubscription: postgresDeleteSubscriptionTopicWithoutSubscriptionQuery, }, }, nil } func setupPostgresDB(db *sql.DB) error { var schemaVersion int - err := db.QueryRow(pgSelectSchemaVersionQuery).Scan(&schemaVersion) + err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { return setupNewPostgresDB(db) } @@ -111,10 +111,10 @@ func setupNewPostgresDB(db *sql.DB) error { return err } defer tx.Rollback() - if _, err := tx.Exec(pgCreateTablesQuery); err != nil { + if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { return err } - if _, err := tx.Exec(pgInsertSchemaVersion, pgCurrentSchemaVersion); err != nil { + if _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil { return err } return tx.Commit() diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 07823672..6022f45f 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -39,9 +39,9 @@ const ( PRAGMA foreign_keys = ON; ` - sqliteSelectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?` - sqliteSelectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?` - sqliteSelectWebPushSubscriptionsForTopicQuery = ` + sqliteSelectWebPushSubscriptionIDByEndpointQuery = `SELECT id FROM subscription WHERE endpoint = ?` + sqliteSelectWebPushSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?` + sqliteSelectWebPushSubscriptionsForTopicQuery = ` SELECT id, endpoint, key_auth, key_p256dh, user_id FROM subscription_topic st JOIN subscription s ON s.id = st.subscription_id @@ -65,15 +65,15 @@ const ( sqliteDeleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?` sqliteDeleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan! - sqliteInsertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)` - sqliteDeleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?` - sqliteDeleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)` + sqliteInsertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)` + sqliteDeleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?` + sqliteDeleteWebPushSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)` ) // SQLite schema management queries const ( sqliteCurrentWebPushSchemaVersion = 1 - sqliteInsertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + sqliteInsertWebPushSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` sqliteSelectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` ) @@ -92,8 +92,8 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { return &commonStore{ db: db, queries: storeQueries{ - selectSubscriptionIDByEndpoint: sqliteSelectWebPushSubscriptionIDByEndpoint, - selectSubscriptionCountBySubscriberIP: sqliteSelectWebPushSubscriptionCountBySubscriberIP, + selectSubscriptionIDByEndpoint: sqliteSelectWebPushSubscriptionIDByEndpointQuery, + selectSubscriptionCountBySubscriberIP: sqliteSelectWebPushSubscriptionCountBySubscriberIPQuery, selectSubscriptionsForTopic: sqliteSelectWebPushSubscriptionsForTopicQuery, selectSubscriptionsExpiringSoon: sqliteSelectWebPushSubscriptionsExpiringSoonQuery, insertSubscription: sqliteInsertWebPushSubscriptionQuery, @@ -104,7 +104,7 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { deleteSubscriptionByAge: sqliteDeleteWebPushSubscriptionByAgeQuery, insertSubscriptionTopic: sqliteInsertWebPushSubscriptionTopicQuery, deleteSubscriptionTopicAll: sqliteDeleteWebPushSubscriptionTopicAllQuery, - deleteSubscriptionTopicWithoutSubscription: sqliteDeleteWebPushSubscriptionTopicWithoutSubscription, + deleteSubscriptionTopicWithoutSubscription: sqliteDeleteWebPushSubscriptionTopicWithoutSubscriptionQuery, }, }, nil } @@ -125,7 +125,7 @@ func setupNewSQLite(db *sql.DB) error { if _, err := db.Exec(sqliteCreateWebPushSubscriptionsTableQuery); err != nil { return err } - if _, err := db.Exec(sqliteInsertWebPushSchemaVersion, sqliteCurrentWebPushSchemaVersion); err != nil { + if _, err := db.Exec(sqliteInsertWebPushSchemaVersionQuery, sqliteCurrentWebPushSchemaVersion); err != nil { return err } return nil From 390cff0604f9fe0fd1d8040b3297e730186550c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Mih=C3=A1ly?= Date: Sat, 21 Feb 2026 13:48:53 +0100 Subject: [PATCH 221/548] Translated using Weblate (Hungarian) Currently translated at 55.2% (225 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/hu/ --- web/public/static/langs/hu.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/hu.json b/web/public/static/langs/hu.json index 45fcd50d..f32b6e2f 100644 --- a/web/public/static/langs/hu.json +++ b/web/public/static/langs/hu.json @@ -215,5 +215,13 @@ "alert_notification_permission_denied_title": "Az értesítések blokkolva vannak", "alert_notification_permission_denied_description": "Kérjük kapcsold őket vissza a böngésződben", "alert_notification_ios_install_required_title": "iOS telepítés szükséges", - "alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a Notifications API korlátozása." + "alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a Notifications API korlátozása.", + "signup_form_toggle_password_visibility": "Jelszó láthatóságának kapcsolása", + "signup_disabled": "A regisztráció le van tiltva", + "action_bar_reservation_add": "Téma fenntartása", + "action_bar_reservation_edit": "Foglalás módosítása", + "action_bar_reservation_delete": "Foglalás törlése", + "nav_upgrade_banner_label": "Frissítés ntfy Pro-ra", + "nav_upgrade_banner_description": "Témák, több üzenet és e-mail, valamint nagyobb mellékletek megőrzése", + "alert_notification_ios_install_required_description": "Kattintson a Megosztás ikonra, majd a Hozzáadás a kezdőképernyőhöz gombra, hogy engedélyezze az értesítéseket iOS rendszeren" } From 8b12bdeb3a247872e7205265c40435e1aeec0da3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Feb 2026 09:07:48 -0500 Subject: [PATCH 222/548] Release notes --- docs/releases.md | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 919b75b7..c2317180 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -7,11 +7,32 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| | ntfy server | v2.17.0 | Feb 8, 2026 | -| ntfy Android app | v1.22.2 | Jan 25, 2026 | +| ntfy Android app | v1.23.0 | Deb 22, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy Android v1.23.0 +Released February 22, 2026 + +This release adds support for search within a topic, and adds [copy action](publish.md#copy-to-clipboard) support +to the Android app. + +**Features:** + +* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring) +* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting) +* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582)) +* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting) +* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting) + +**Bug fixes + maintenance:** + +* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting) +* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1)) +* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting) +* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting) + ## ntfy server v2.17.0 Released February 8, 2026 @@ -1703,20 +1724,3 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** * Preserve `
    ` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) - -### ntfy Android v1.23.x (UNRELEASED) - -**Features:** - -* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring) -* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting) -* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582)) -* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting) -* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting) - -**Bug fixes + maintenance:** - -* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting) -* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1)) -* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting) -* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting) From f726cc768ec2b2f6897b0c785b372696f2758079 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Feb 2026 14:25:15 -0500 Subject: [PATCH 223/548] Manual refactor --- webpush/store.go | 7 +++---- webpush/store_postgres.go | 4 ++-- webpush/store_sqlite.go | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/webpush/store.go b/webpush/store.go index a318f2b4..81800431 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -40,7 +40,7 @@ type storeQueries struct { selectSubscriptionCountBySubscriberIP string selectSubscriptionsForTopic string selectSubscriptionsExpiringSoon string - insertSubscription string + upsertSubscription string updateSubscriptionWarningSent string updateSubscriptionUpdatedAt string deleteSubscriptionByEndpoint string @@ -71,8 +71,7 @@ func (s *commonStore) UpsertSubscription(endpoint string, auth, p256dh, userID s } // Read existing subscription ID for endpoint (or create new ID) var subscriptionID string - err = tx.QueryRow(s.queries.selectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID) - if errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRow(s.queries.selectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID); errors.Is(err, sql.ErrNoRows) { if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { return ErrWebPushTooManySubscriptions } @@ -82,7 +81,7 @@ func (s *commonStore) UpsertSubscription(endpoint string, auth, p256dh, userID s } // Insert or update subscription updatedAt, warnedAt := time.Now().Unix(), 0 - if _, err = tx.Exec(s.queries.insertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { + if _, err := tx.Exec(s.queries.upsertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { return err } // Replace all subscription topics diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index 831b50f3..9fb7f9f1 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -44,7 +44,7 @@ const ( FROM webpush_subscription WHERE warned_at = 0 AND updated_at <= $1 ` - postgresInsertSubscriptionQuery = ` + postgresUpsertSubscriptionQuery = ` INSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (endpoint) @@ -80,7 +80,7 @@ func NewPostgresStore(db *sql.DB) (Store, error) { selectSubscriptionCountBySubscriberIP: postgresSelectSubscriptionCountBySubscriberIPQuery, selectSubscriptionsForTopic: postgresSelectSubscriptionsForTopicQuery, selectSubscriptionsExpiringSoon: postgresSelectSubscriptionsExpiringSoonQuery, - insertSubscription: postgresInsertSubscriptionQuery, + upsertSubscription: postgresUpsertSubscriptionQuery, updateSubscriptionWarningSent: postgresUpdateSubscriptionWarningSentQuery, updateSubscriptionUpdatedAt: postgresUpdateSubscriptionUpdatedAtQuery, deleteSubscriptionByEndpoint: postgresDeleteSubscriptionByEndpointQuery, diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 6022f45f..7fe4ba3f 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -96,7 +96,7 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { selectSubscriptionCountBySubscriberIP: sqliteSelectWebPushSubscriptionCountBySubscriberIPQuery, selectSubscriptionsForTopic: sqliteSelectWebPushSubscriptionsForTopicQuery, selectSubscriptionsExpiringSoon: sqliteSelectWebPushSubscriptionsExpiringSoonQuery, - insertSubscription: sqliteInsertWebPushSubscriptionQuery, + upsertSubscription: sqliteInsertWebPushSubscriptionQuery, updateSubscriptionWarningSent: sqliteUpdateWebPushSubscriptionWarningSentQuery, updateSubscriptionUpdatedAt: sqliteUpdateWebPushSubscriptionUpdatedAtQuery, deleteSubscriptionByEndpoint: sqliteDeleteWebPushSubscriptionByEndpointQuery, From 35a54407a89355e7953344613733ac2422175461 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Feb 2026 15:12:54 -0500 Subject: [PATCH 224/548] Manual review --- user/store.go | 40 +++++++-------- user/store_postgres.go | 44 ++++++++-------- user/store_sqlite.go | 114 ++++++++++++++++++++--------------------- 3 files changed, 99 insertions(+), 99 deletions(-) diff --git a/user/store.go b/user/store.go index 4dbc2bad..8b958c3c 100644 --- a/user/store.go +++ b/user/store.go @@ -82,25 +82,25 @@ type Store interface { // storeQueries holds the database-specific SQL queries type storeQueries struct { // User queries - selectUserByID string - selectUserByName string - selectUserByToken string - selectUserByStripeID string - selectUsernames string - selectUserCount string - selectUserIDFromUsername string - insertUser string - updateUserPass string - updateUserRole string - updateUserProvisioned string - updateUserPrefs string - updateUserStats string - updateUserStatsResetAll string - updateUserTier string - updateUserDeleted string - deleteUser string - deleteUserTier string - deleteUsersMarked string + selectUserByID string + selectUserByName string + selectUserByToken string + selectUserByStripeCustomerID string + selectUsernames string + selectUserCount string + selectUserIDFromUsername string + insertUser string + updateUserPass string + updateUserRole string + updateUserProvisioned string + updateUserPrefs string + updateUserStats string + updateUserStatsResetAll string + updateUserTier string + updateUserDeleted string + deleteUser string + deleteUserTier string + deleteUsersMarked string // Access queries selectTopicPerms string selectUserAllAccess string @@ -179,7 +179,7 @@ func (s *commonStore) UserByToken(token string) (*User, error) { // UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise func (s *commonStore) UserByStripeCustomer(customerID string) (*User, error) { - rows, err := s.db.Query(s.queries.selectUserByStripeID, customerID) + rows, err := s.db.Query(s.queries.selectUserByStripeCustomerID, customerID) if err != nil { return nil, err } diff --git a/user/store_postgres.go b/user/store_postgres.go index 666fd4f4..229bfd6d 100644 --- a/user/store_postgres.go +++ b/user/store_postgres.go @@ -26,7 +26,7 @@ const ( LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = $1 AND (tk.expires = 0 OR tk.expires >= $2) ` - postgresSelectUserByStripeIDQuery = ` + postgresSelectUserByStripeCustomerIDQuery = ` SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM "user" u LEFT JOIN tier t on t.id = u.tier_id @@ -120,7 +120,7 @@ const ( $7 ) ON CONFLICT (user_id, topic) - DO UPDATE SET read=EXCLUDED.read, write=EXCLUDED.write, owner_user_id=EXCLUDED.owner_user_id, provisioned=EXCLUDED.provisioned + DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned ` postgresDeleteUserAccessQuery = ` DELETE FROM user_access @@ -144,7 +144,7 @@ const ( INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, token) - DO UPDATE SET label = EXCLUDED.label, expires = EXCLUDED.expires, provisioned = EXCLUDED.provisioned + DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned ` postgresUpdateTokenLabelQuery = `UPDATE user_token SET label = $1 WHERE user_id = $2 AND token = $3` postgresUpdateTokenExpiryQuery = `UPDATE user_token SET expires = $1 WHERE user_id = $2 AND token = $3` @@ -213,25 +213,25 @@ func NewPostgresStore(db *sql.DB) (Store, error) { db: db, queries: storeQueries{ // User queries - selectUserByID: postgresSelectUserByIDQuery, - selectUserByName: postgresSelectUserByNameQuery, - selectUserByToken: postgresSelectUserByTokenQuery, - selectUserByStripeID: postgresSelectUserByStripeIDQuery, - selectUsernames: postgresSelectUsernamesQuery, - selectUserCount: postgresSelectUserCountQuery, - selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery, - insertUser: postgresInsertUserQuery, - updateUserPass: postgresUpdateUserPassQuery, - updateUserRole: postgresUpdateUserRoleQuery, - updateUserProvisioned: postgresUpdateUserProvisionedQuery, - updateUserPrefs: postgresUpdateUserPrefsQuery, - updateUserStats: postgresUpdateUserStatsQuery, - updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery, - updateUserTier: postgresUpdateUserTierQuery, - updateUserDeleted: postgresUpdateUserDeletedQuery, - deleteUser: postgresDeleteUserQuery, - deleteUserTier: postgresDeleteUserTierQuery, - deleteUsersMarked: postgresDeleteUsersMarkedQuery, + selectUserByID: postgresSelectUserByIDQuery, + selectUserByName: postgresSelectUserByNameQuery, + selectUserByToken: postgresSelectUserByTokenQuery, + selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery, + selectUsernames: postgresSelectUsernamesQuery, + selectUserCount: postgresSelectUserCountQuery, + selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery, + insertUser: postgresInsertUserQuery, + updateUserPass: postgresUpdateUserPassQuery, + updateUserRole: postgresUpdateUserRoleQuery, + updateUserProvisioned: postgresUpdateUserProvisionedQuery, + updateUserPrefs: postgresUpdateUserPrefsQuery, + updateUserStats: postgresUpdateUserStatsQuery, + updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery, + updateUserTier: postgresUpdateUserTierQuery, + updateUserDeleted: postgresUpdateUserDeletedQuery, + deleteUser: postgresDeleteUserQuery, + deleteUserTier: postgresDeleteUserTierQuery, + deleteUsersMarked: postgresDeleteUsersMarkedQuery, // Access queries selectTopicPerms: postgresSelectTopicPermsQuery, diff --git a/user/store_sqlite.go b/user/store_sqlite.go index 6325e467..c90a533e 100644 --- a/user/store_sqlite.go +++ b/user/store_sqlite.go @@ -27,7 +27,7 @@ const ( LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` - sqliteSelectUserByStripeIDQuery = ` + sqliteSelectUserByStripeCustomerIDQuery = ` SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id @@ -138,7 +138,7 @@ const ( INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, token) - DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned; + DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned ` sqliteUpdateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` sqliteUpdateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` @@ -213,61 +213,61 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { return &commonStore{ db: db, queries: storeQueries{ - selectUserByID: sqliteSelectUserByIDQuery, - selectUserByName: sqliteSelectUserByNameQuery, - selectUserByToken: sqliteSelectUserByTokenQuery, - selectUserByStripeID: sqliteSelectUserByStripeIDQuery, - selectUsernames: sqliteSelectUsernamesQuery, - selectUserCount: sqliteSelectUserCountQuery, - selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery, - insertUser: sqliteInsertUserQuery, - updateUserPass: sqliteUpdateUserPassQuery, - updateUserRole: sqliteUpdateUserRoleQuery, - updateUserProvisioned: sqliteUpdateUserProvisionedQuery, - updateUserPrefs: sqliteUpdateUserPrefsQuery, - updateUserStats: sqliteUpdateUserStatsQuery, - updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery, - updateUserTier: sqliteUpdateUserTierQuery, - updateUserDeleted: sqliteUpdateUserDeletedQuery, - deleteUser: sqliteDeleteUserQuery, - deleteUserTier: sqliteDeleteUserTierQuery, - deleteUsersMarked: sqliteDeleteUsersMarkedQuery, - selectTopicPerms: sqliteSelectTopicPermsQuery, - selectUserAllAccess: sqliteSelectUserAllAccessQuery, - selectUserAccess: sqliteSelectUserAccessQuery, - selectUserReservations: sqliteSelectUserReservationsQuery, - selectUserReservationsCount: sqliteSelectUserReservationsCountQuery, - selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery, - selectUserHasReservation: sqliteSelectUserHasReservationQuery, - selectOtherAccessCount: sqliteSelectOtherAccessCountQuery, - upsertUserAccess: sqliteUpsertUserAccessQuery, - deleteUserAccess: sqliteDeleteUserAccessQuery, - deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery, - deleteTopicAccess: sqliteDeleteTopicAccessQuery, - deleteAllAccess: sqliteDeleteAllAccessQuery, - selectToken: sqliteSelectTokenQuery, - selectTokens: sqliteSelectTokensQuery, - selectTokenCount: sqliteSelectTokenCountQuery, - selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, - upsertToken: sqliteUpsertTokenQuery, - updateTokenLabel: sqliteUpdateTokenLabelQuery, - updateTokenExpiry: sqliteUpdateTokenExpiryQuery, - updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, - deleteToken: sqliteDeleteTokenQuery, - deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, - deleteAllToken: sqliteDeleteAllTokenQuery, - deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, - deleteExcessTokens: sqliteDeleteExcessTokensQuery, - insertTier: sqliteInsertTierQuery, - selectTiers: sqliteSelectTiersQuery, - selectTierByCode: sqliteSelectTierByCodeQuery, - selectTierByPriceID: sqliteSelectTierByPriceIDQuery, - updateTier: sqliteUpdateTierQuery, - deleteTier: sqliteDeleteTierQuery, - selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, - insertPhoneNumber: sqliteInsertPhoneNumberQuery, - deletePhoneNumber: sqliteDeletePhoneNumberQuery, - updateBilling: sqliteUpdateBillingQuery, + selectUserByID: sqliteSelectUserByIDQuery, + selectUserByName: sqliteSelectUserByNameQuery, + selectUserByToken: sqliteSelectUserByTokenQuery, + selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery, + selectUsernames: sqliteSelectUsernamesQuery, + selectUserCount: sqliteSelectUserCountQuery, + selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery, + insertUser: sqliteInsertUserQuery, + updateUserPass: sqliteUpdateUserPassQuery, + updateUserRole: sqliteUpdateUserRoleQuery, + updateUserProvisioned: sqliteUpdateUserProvisionedQuery, + updateUserPrefs: sqliteUpdateUserPrefsQuery, + updateUserStats: sqliteUpdateUserStatsQuery, + updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery, + updateUserTier: sqliteUpdateUserTierQuery, + updateUserDeleted: sqliteUpdateUserDeletedQuery, + deleteUser: sqliteDeleteUserQuery, + deleteUserTier: sqliteDeleteUserTierQuery, + deleteUsersMarked: sqliteDeleteUsersMarkedQuery, + selectTopicPerms: sqliteSelectTopicPermsQuery, + selectUserAllAccess: sqliteSelectUserAllAccessQuery, + selectUserAccess: sqliteSelectUserAccessQuery, + selectUserReservations: sqliteSelectUserReservationsQuery, + selectUserReservationsCount: sqliteSelectUserReservationsCountQuery, + selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery, + selectUserHasReservation: sqliteSelectUserHasReservationQuery, + selectOtherAccessCount: sqliteSelectOtherAccessCountQuery, + upsertUserAccess: sqliteUpsertUserAccessQuery, + deleteUserAccess: sqliteDeleteUserAccessQuery, + deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery, + deleteTopicAccess: sqliteDeleteTopicAccessQuery, + deleteAllAccess: sqliteDeleteAllAccessQuery, + selectToken: sqliteSelectTokenQuery, + selectTokens: sqliteSelectTokensQuery, + selectTokenCount: sqliteSelectTokenCountQuery, + selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, + upsertToken: sqliteUpsertTokenQuery, + updateTokenLabel: sqliteUpdateTokenLabelQuery, + updateTokenExpiry: sqliteUpdateTokenExpiryQuery, + updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, + deleteToken: sqliteDeleteTokenQuery, + deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, + deleteAllToken: sqliteDeleteAllTokenQuery, + deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, + deleteExcessTokens: sqliteDeleteExcessTokensQuery, + insertTier: sqliteInsertTierQuery, + selectTiers: sqliteSelectTiersQuery, + selectTierByCode: sqliteSelectTierByCodeQuery, + selectTierByPriceID: sqliteSelectTierByPriceIDQuery, + updateTier: sqliteUpdateTierQuery, + deleteTier: sqliteDeleteTierQuery, + selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, + insertPhoneNumber: sqliteInsertPhoneNumberQuery, + deletePhoneNumber: sqliteDeletePhoneNumberQuery, + updateBilling: sqliteUpdateBillingQuery, }, }, nil } From 43280fbc0aa1591b810d3747258903923edf37e3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Feb 2026 16:21:27 -0500 Subject: [PATCH 225/548] Performance improvements in pg --- message/store.go | 91 +++++++++++++++----------------- message/store_postgres.go | 21 ++++---- message/store_postgres_schema.go | 10 ++-- message/store_sqlite.go | 19 +++---- message/store_test.go | 26 +++------ model/model.go | 2 +- server/server_manager.go | 11 ++-- webpush/store_postgres.go | 2 + 8 files changed, 78 insertions(+), 104 deletions(-) diff --git a/message/store.go b/message/store.go index ed6168fd..ba3ddc81 100644 --- a/message/store.go +++ b/message/store.go @@ -25,7 +25,7 @@ type Store interface { AddMessage(m *model.Message) error AddMessages(ms []*model.Message) error Message(id string) (*model.Message, error) - MessageCounts() (map[string]int, error) + MessagesCount() (int, error) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) MessagesDue() ([]*model.Message, error) MessagesExpired() ([]string, error) @@ -51,7 +51,6 @@ type storeQueries struct { selectScheduledMessageIDsBySeqID string deleteScheduledBySequenceID string updateMessagesForTopicExpiry string - selectRowIDFromMessageID string selectMessagesByID string selectMessagesSinceTime string selectMessagesSinceTimeScheduled string @@ -62,7 +61,6 @@ type storeQueries struct { selectMessagesExpired string updateMessagePublished string selectMessagesCount string - selectMessageCountPerTopic string selectTopics string updateAttachmentDeleted string selectAttachmentsExpired string @@ -78,11 +76,11 @@ type commonStore struct { db *sql.DB queue *util.BatchingQueue[*model.Message] nop bool - mu sync.Mutex + mu *sync.Mutex // nil for PostgreSQL (concurrent writes supported), set for SQLite (single writer) queries storeQueries } -func newCommonStore(db *sql.DB, queries storeQueries, batchSize int, batchTimeout time.Duration, nop bool) *commonStore { +func newCommonStore(db *sql.DB, queries storeQueries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *commonStore { var queue *util.BatchingQueue[*model.Message] if batchSize > 0 || batchTimeout > 0 { queue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout) @@ -91,12 +89,25 @@ func newCommonStore(db *sql.DB, queries storeQueries, batchSize int, batchTimeou db: db, queue: queue, nop: nop, + mu: mu, queries: queries, } go c.processMessageBatches() return c } +func (c *commonStore) maybeLock() { + if c.mu != nil { + c.mu.Lock() + } +} + +func (c *commonStore) maybeUnlock() { + if c.mu != nil { + c.mu.Unlock() + } +} + // AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asynchronously. // The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor. func (c *commonStore) AddMessage(m *model.Message) error { @@ -113,8 +124,8 @@ func (c *commonStore) AddMessages(ms []*model.Message) error { } func (c *commonStore) addMessages(ms []*model.Message) error { - c.mu.Lock() - defer c.mu.Unlock() + c.maybeLock() + defer c.maybeUnlock() if c.nop { return nil } @@ -224,24 +235,12 @@ func (c *commonStore) messagesSinceTime(topic string, since model.SinceMarker, s } func (c *commonStore) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { - idrows, err := c.db.Query(c.queries.selectRowIDFromMessageID, since.ID()) - if err != nil { - return nil, err - } - defer idrows.Close() - if !idrows.Next() { - return c.messagesSinceTime(topic, model.SinceAllMessages, scheduled) - } - var rowID int64 - if err := idrows.Scan(&rowID); err != nil { - return nil, err - } - idrows.Close() var rows *sql.Rows + var err error if scheduled { - rows, err = c.db.Query(c.queries.selectMessagesSinceIDScheduled, topic, rowID) + rows, err = c.db.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID()) } else { - rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, rowID) + rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, since.ID()) } if err != nil { return nil, err @@ -305,30 +304,26 @@ func (c *commonStore) UpdateMessageTime(messageID string, timestamp int64) error } func (c *commonStore) MarkPublished(m *model.Message) error { - c.mu.Lock() - defer c.mu.Unlock() + c.maybeLock() + defer c.maybeUnlock() _, err := c.db.Exec(c.queries.updateMessagePublished, m.ID) return err } -func (c *commonStore) MessageCounts() (map[string]int, error) { - rows, err := c.db.Query(c.queries.selectMessageCountPerTopic) +func (c *commonStore) MessagesCount() (int, error) { + rows, err := c.db.Query(c.queries.selectMessagesCount) if err != nil { - return nil, err + return 0, err } defer rows.Close() - var topic string - var count int - counts := make(map[string]int) - for rows.Next() { - if err := rows.Scan(&topic, &count); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - counts[topic] = count + if !rows.Next() { + return 0, errNoRows } - return counts, nil + var count int + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil } func (c *commonStore) Topics() ([]string, error) { @@ -352,8 +347,8 @@ func (c *commonStore) Topics() ([]string, error) { } func (c *commonStore) DeleteMessages(ids ...string) error { - c.mu.Lock() - defer c.mu.Unlock() + c.maybeLock() + defer c.maybeUnlock() tx, err := c.db.Begin() if err != nil { return err @@ -370,8 +365,8 @@ func (c *commonStore) DeleteMessages(ids ...string) error { // DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID. // It returns the message IDs of the deleted messages, which can be used to clean up attachment files. func (c *commonStore) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) { - c.mu.Lock() - defer c.mu.Unlock() + c.maybeLock() + defer c.maybeUnlock() tx, err := c.db.Begin() if err != nil { return nil, err @@ -406,8 +401,8 @@ func (c *commonStore) DeleteScheduledBySequenceID(topic, sequenceID string) ([]s } func (c *commonStore) ExpireMessages(topics ...string) error { - c.mu.Lock() - defer c.mu.Unlock() + c.maybeLock() + defer c.maybeUnlock() tx, err := c.db.Begin() if err != nil { return err @@ -442,8 +437,8 @@ func (c *commonStore) AttachmentsExpired() ([]string, error) { } func (c *commonStore) MarkAttachmentsDeleted(ids ...string) error { - c.mu.Lock() - defer c.mu.Unlock() + c.maybeLock() + defer c.maybeUnlock() tx, err := c.db.Begin() if err != nil { return err @@ -488,8 +483,8 @@ func (c *commonStore) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) { } func (c *commonStore) UpdateStats(messages int64) error { - c.mu.Lock() - defer c.mu.Unlock() + c.maybeLock() + defer c.maybeUnlock() _, err := c.db.Exec(c.queries.updateStats, messages) return err } diff --git a/message/store_postgres.go b/message/store_postgres.go index 4fb052c8..a21b8f62 100644 --- a/message/store_postgres.go +++ b/message/store_postgres.go @@ -15,7 +15,6 @@ const ( postgresSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` postgresDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` postgresUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2` - postgresSelectRowIDFromMessageIDQuery = `SELECT id FROM message WHERE mid = $1` postgresSelectMessagesByIDQuery = ` 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 @@ -36,13 +35,16 @@ const ( postgresSelectMessagesSinceIDQuery = ` 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 topic = $1 AND id > $2 AND published = TRUE + WHERE topic = $1 + AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) + AND published = TRUE ORDER BY time, id ` postgresSelectMessagesSinceIDIncludeScheduledQuery = ` 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 topic = $1 AND (id > $2 OR published = FALSE) + WHERE topic = $1 + AND (id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) OR published = FALSE) ORDER BY time, id ` postgresSelectMessagesLatestQuery = ` @@ -58,11 +60,10 @@ const ( WHERE time <= $1 AND published = FALSE ORDER BY time, id ` - postgresSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE` - postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1` - postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message` - postgresSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM message GROUP BY topic` - postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic` + postgresSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE` + postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1` + postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message` + postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic` postgresUpdateAttachmentDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1` postgresSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE` @@ -80,7 +81,6 @@ var pgQueries = storeQueries{ selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery, deleteScheduledBySequenceID: postgresDeleteScheduledBySequenceIDQuery, updateMessagesForTopicExpiry: postgresUpdateMessagesForTopicExpiryQuery, - selectRowIDFromMessageID: postgresSelectRowIDFromMessageIDQuery, selectMessagesByID: postgresSelectMessagesByIDQuery, selectMessagesSinceTime: postgresSelectMessagesSinceTimeQuery, selectMessagesSinceTimeScheduled: postgresSelectMessagesSinceTimeIncludeScheduledQuery, @@ -91,7 +91,6 @@ var pgQueries = storeQueries{ selectMessagesExpired: postgresSelectMessagesExpiredQuery, updateMessagePublished: postgresUpdateMessagePublishedQuery, selectMessagesCount: postgresSelectMessagesCountQuery, - selectMessageCountPerTopic: postgresSelectMessageCountPerTopicQuery, selectTopics: postgresSelectTopicsQuery, updateAttachmentDeleted: postgresUpdateAttachmentDeletedQuery, selectAttachmentsExpired: postgresSelectAttachmentsExpiredQuery, @@ -107,5 +106,5 @@ func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (St if err := setupPostgresDB(db); err != nil { return nil, err } - return newCommonStore(db, pgQueries, batchSize, batchTimeout, false), nil + return newCommonStore(db, pgQueries, nil, batchSize, batchTimeout, false), nil } diff --git a/message/store_postgres_schema.go b/message/store_postgres_schema.go index 6e4d654c..a647cdf9 100644 --- a/message/store_postgres_schema.go +++ b/message/store_postgres_schema.go @@ -37,12 +37,10 @@ const ( ); CREATE INDEX IF NOT EXISTS idx_message_mid ON message (mid); CREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id); - CREATE INDEX IF NOT EXISTS idx_message_time ON message (time); - CREATE INDEX IF NOT EXISTS idx_message_topic ON message (topic); - CREATE INDEX IF NOT EXISTS idx_message_expires ON message (expires); - CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender); - CREATE INDEX IF NOT EXISTS idx_message_user_id ON message (user_id); - CREATE INDEX IF NOT EXISTS idx_message_attachment_expires ON message (attachment_expires); + CREATE INDEX IF NOT EXISTS idx_message_topic_published_time ON message (topic, published, time, id); + CREATE INDEX IF NOT EXISTS idx_message_published_expires ON message (published, expires); + CREATE INDEX IF NOT EXISTS idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = ''; + CREATE INDEX IF NOT EXISTS idx_message_user_id_attachment_expires ON message (user_id, attachment_expires); CREATE TABLE IF NOT EXISTS message_stats ( key TEXT PRIMARY KEY, value BIGINT diff --git a/message/store_sqlite.go b/message/store_sqlite.go index 646e3975..923f3480 100644 --- a/message/store_sqlite.go +++ b/message/store_sqlite.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "path/filepath" + "sync" "time" _ "github.com/mattn/go-sqlite3" // SQLite driver @@ -20,7 +21,6 @@ const ( sqliteSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` sqliteDeleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` sqliteUpdateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` - sqliteSelectRowIDFromMessageIDQuery = `SELECT id FROM messages WHERE mid = ?` sqliteSelectMessagesByIDQuery = ` 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, content_type, encoding FROM messages @@ -41,13 +41,13 @@ const ( sqliteSelectMessagesSinceIDQuery = ` 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, content_type, encoding FROM messages - WHERE topic = ? AND id > ? AND published = 1 + WHERE topic = ? AND id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) AND published = 1 ORDER BY time, id ` sqliteSelectMessagesSinceIDIncludeScheduledQuery = ` 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, content_type, encoding FROM messages - WHERE topic = ? AND (id > ? OR published = 0) + WHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0) ORDER BY time, id ` sqliteSelectMessagesLatestQuery = ` @@ -63,11 +63,10 @@ const ( WHERE time <= ? AND published = 0 ORDER BY time, id ` - sqliteSelectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` - sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` - sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages` - sqliteSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` - sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` + sqliteSelectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` + sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages` + sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` sqliteUpdateAttachmentDeletedQuery = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` sqliteSelectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` @@ -85,7 +84,6 @@ var sqliteQueries = storeQueries{ selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery, deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery, updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery, - selectRowIDFromMessageID: sqliteSelectRowIDFromMessageIDQuery, selectMessagesByID: sqliteSelectMessagesByIDQuery, selectMessagesSinceTime: sqliteSelectMessagesSinceTimeQuery, selectMessagesSinceTimeScheduled: sqliteSelectMessagesSinceTimeIncludeScheduledQuery, @@ -96,7 +94,6 @@ var sqliteQueries = storeQueries{ selectMessagesExpired: sqliteSelectMessagesExpiredQuery, updateMessagePublished: sqliteUpdateMessagePublishedQuery, selectMessagesCount: sqliteSelectMessagesCountQuery, - selectMessageCountPerTopic: sqliteSelectMessageCountPerTopicQuery, selectTopics: sqliteSelectTopicsQuery, updateAttachmentDeleted: sqliteUpdateAttachmentDeletedQuery, selectAttachmentsExpired: sqliteSelectAttachmentsExpiredQuery, @@ -120,7 +117,7 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration if err := setupSQLite(db, startupQueries, cacheDuration); err != nil { return nil, err } - return newCommonStore(db, sqliteQueries, batchSize, batchTimeout, nop), nil + return newCommonStore(db, sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil } // NewMemStore creates an in-memory cache diff --git a/message/store_test.go b/message/store_test.go index 50bf84f3..e53c11ec 100644 --- a/message/store_test.go +++ b/message/store_test.go @@ -65,10 +65,10 @@ func TestStore_Messages(t *testing.T) { require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewKeepaliveMessage("mytopic"))) // These should not be added! require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewOpenMessage("example"))) // These should not be added! - // mytopic: count - counts, err := s.MessageCounts() + // count + count, err := s.MessagesCount() require.Nil(t, err) - require.Equal(t, 2, counts["mytopic"]) + require.Equal(t, 3, count) // mytopic: since all messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) @@ -102,20 +102,10 @@ func TestStore_Messages(t *testing.T) { require.Equal(t, 1, len(messages)) require.Equal(t, "my other message", messages[0].Message) - // example: count - counts, err = s.MessageCounts() - require.Nil(t, err) - require.Equal(t, 1, counts["example"]) - // example: since all messages, _ = s.Messages("example", model.SinceAllMessages, false) require.Equal(t, "my example message", messages[0].Message) - // non-existing: count - counts, err = s.MessageCounts() - require.Nil(t, err) - require.Equal(t, 0, counts["doesnotexist"]) - // non-existing: since all messages, _ = s.Messages("doesnotexist", model.SinceAllMessages, false) require.Empty(t, messages) @@ -280,19 +270,17 @@ func TestStore_Prune(t *testing.T) { require.Nil(t, s.AddMessage(m2)) require.Nil(t, s.AddMessage(m3)) - counts, err := s.MessageCounts() + count, err := s.MessagesCount() require.Nil(t, err) - require.Equal(t, 2, counts["mytopic"]) - require.Equal(t, 1, counts["another_topic"]) + require.Equal(t, 3, count) expiredMessageIDs, err := s.MessagesExpired() require.Nil(t, err) require.Nil(t, s.DeleteMessages(expiredMessageIDs...)) - counts, err = s.MessageCounts() + count, err = s.MessagesCount() require.Nil(t, err) - require.Equal(t, 1, counts["mytopic"]) - require.Equal(t, 0, counts["another_topic"]) + require.Equal(t, 1, count) messages, err := s.Messages("mytopic", model.SinceAllMessages, false) require.Nil(t, err) diff --git a/model/model.go b/model/model.go index b0e2d259..a8ecdf78 100644 --- a/model/model.go +++ b/model/model.go @@ -191,7 +191,7 @@ func (t SinceMarker) IsLatest() bool { // IsID returns true if this marker references a specific message ID func (t SinceMarker) IsID() bool { - return t.id != "" && t.id != "latest" + return t.id != "" && t.id != SinceLatestMessage.id } // Time returns the time component of the marker diff --git a/server/server_manager.go b/server/server_manager.go index 9f5fe888..afed7b33 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -17,15 +17,10 @@ func (s *Server) execManager() { s.pruneMessages() s.pruneAndNotifyWebPushSubscriptions() - // Message count per topic - var messagesCached int - messageCounts, err := s.messageCache.MessageCounts() + // Message count + messagesCached, err := s.messageCache.MessagesCount() if err != nil { - log.Tag(tagManager).Err(err).Warn("Cannot get message counts") - messageCounts = make(map[string]int) // Empty, so we can continue - } - for _, count := range messageCounts { - messagesCached += count + log.Tag(tagManager).Err(err).Warn("Cannot get messages count") } // Remove subscriptions without subscribers diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index 9fb7f9f1..5dd72e70 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -18,6 +18,8 @@ const ( warned_at BIGINT NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip); + CREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription (updated_at); + CREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription (user_id); CREATE TABLE IF NOT EXISTS webpush_subscription_topic ( subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE, topic TEXT NOT NULL, From 850a9d4cc42dc2398d3d8cb4a5ff0bc8cb92035c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Feb 2026 20:28:37 -0500 Subject: [PATCH 226/548] Fix bug with provisioned token removal --- user/manager.go | 2 +- user/manager_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++ user/store.go | 12 +++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/user/manager.go b/user/manager.go index 1e1b27f8..534ff86d 100644 --- a/user/manager.go +++ b/user/manager.go @@ -763,7 +763,7 @@ func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { } for _, existingToken := range existingTokens { if !slices.Contains(provisionTokens, existingToken.Value) { - if err := a.store.RemoveToken("", existingToken.Value); err != nil { + if err := a.store.RemoveProvisionedToken(existingToken.Value); err != nil { return fmt.Errorf("failed to remove provisioned token %s: %v", existingToken.Value, err) } } diff --git a/user/manager_test.go b/user/manager_test.go index d903332f..b1858ad4 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1313,6 +1313,53 @@ func TestManager_WithProvisionedUsers(t *testing.T) { }) } +func TestManager_WithProvisionedUsers_RemoveToken(t *testing.T) { + forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + conf := &Config{ + DefaultAccess: PermissionReadWrite, + ProvisionEnabled: true, + Users: []*User{ + {Name: "phil", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + Tokens: map[string][]*Token{ + "phil": { + {Value: "tk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Label: "Token A"}, + {Value: "tk_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Label: "Token B"}, + }, + }, + } + a := newTestManagerFromStoreConfig(t, newStore, conf) + + users, err := a.Users() + require.Nil(t, err) + philUserID := "" + for _, u := range users { + if u.Name == "phil" { + philUserID = u.ID + } + } + require.NotEmpty(t, philUserID) + + tokens, err := a.Tokens(philUserID) + require.Nil(t, err) + require.Equal(t, 2, len(tokens)) + + // Re-open the DB: user stays, but Token B is removed from config + require.Nil(t, a.Close()) + conf.Tokens = map[string][]*Token{ + "phil": { + {Value: "tk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Label: "Token A"}, + }, + } + a = newTestManagerFromStoreConfig(t, newStore, conf) + + tokens, err = a.Tokens(philUserID) + require.Nil(t, err) + require.Equal(t, 1, len(tokens)) + require.Equal(t, "tk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", tokens[0].Value) + }) +} + func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { conf := &Config{ diff --git a/user/store.go b/user/store.go index 8b958c3c..53598b14 100644 --- a/user/store.go +++ b/user/store.go @@ -44,6 +44,7 @@ type Store interface { ChangeTokenExpiry(userID, token string, expires time.Time) error UpdateTokenLastAccess(token string, lastAccess time.Time, lastOrigin netip.Addr) error RemoveToken(userID, token string) error + RemoveProvisionedToken(token string) error RemoveExpiredTokens() error TokenCount(userID string) (int, error) RemoveExcessTokens(userID string, maxCount int) error @@ -538,6 +539,17 @@ func (s *commonStore) RemoveToken(userID, token string) error { return nil } +// RemoveProvisionedToken deletes a provisioned token by value, regardless of user +func (s *commonStore) RemoveProvisionedToken(token string) error { + if token == "" { + return errNoTokenProvided + } + if _, err := s.db.Exec(s.queries.deleteProvisionedToken, token); err != nil { + return err + } + return nil +} + // RemoveExpiredTokens deletes all expired tokens from the database func (s *commonStore) RemoveExpiredTokens() error { if _, err := s.db.Exec(s.queries.deleteExpiredTokens, time.Now().Unix()); err != nil { From 811c7ae25aa4c4a04dbb1c275bc77fb50368d36e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Feb 2026 20:39:41 -0500 Subject: [PATCH 227/548] TX fixes --- user/store.go | 8 ++++++-- user/store_sqlite.go | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/user/store.go b/user/store.go index 53598b14..0784ae42 100644 --- a/user/store.go +++ b/user/store.go @@ -269,8 +269,12 @@ func (s *commonStore) MarkUserRemoved(userID string) error { return err } defer tx.Rollback() - // Get username for deleteUserAccess query - user, err := s.UserByID(userID) + // Get username for deleteUserAccess query (must be inside tx for consistency) + rows, err := tx.Query(s.queries.selectUserByID, userID) + if err != nil { + return err + } + user, err := s.readUser(rows) if err != nil { return err } diff --git a/user/store_sqlite.go b/user/store_sqlite.go index c90a533e..57c61f0c 100644 --- a/user/store_sqlite.go +++ b/user/store_sqlite.go @@ -2,8 +2,12 @@ package user import ( "database/sql" + "fmt" + "path/filepath" _ "github.com/mattn/go-sqlite3" // SQLite driver + + "heckel.io/ntfy/v2/util" ) const ( @@ -200,6 +204,10 @@ const ( // NewSQLiteStore creates a new SQLite-backed user store func NewSQLiteStore(filename, startupQueries string) (Store, error) { + parentDir := filepath.Dir(filename) + if !util.FileExists(parentDir) { + return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir) + } db, err := sql.Open("sqlite3", filename) if err != nil { return nil, err From 0d375d3a08ceb95fdd57629174ab1ce152cfdc77 Mon Sep 17 00:00:00 2001 From: acress1 <134766617+acress1@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:22:38 +0100 Subject: [PATCH 228/548] Update ntfy.tedomum.net URL to ntfy.tedomum.fr --- docs/integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations.md b/docs/integrations.md index a5a22530..a23a61be 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -306,7 +306,7 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!** | URL | Country | |---------------------------------------------------|--------------------| | [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States | -| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France | +| [ntfy.tedomum.fr](https://ntfy.tedomum.fr/) | 🇫🇷 France | | [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland | | [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany | | [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany | From 90d0eca14de5d4f317775b155664db3227a66f23 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Feb 2026 11:17:57 -0500 Subject: [PATCH 229/548] Consistency --- message/store.go | 2 ++ webpush/store.go | 2 +- webpush/store_sqlite.go | 15 +++++++++------ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/message/store.go b/message/store.go index ba3ddc81..11d261f0 100644 --- a/message/store.go +++ b/message/store.go @@ -299,6 +299,8 @@ func (c *commonStore) Message(id string) (*model.Message, error) { // UpdateMessageTime updates the time column for a message by ID. This is only used for testing. func (c *commonStore) UpdateMessageTime(messageID string, timestamp int64) error { + c.maybeLock() + defer c.maybeUnlock() _, err := c.db.Exec(c.queries.updateMessageTime, timestamp, messageID) return err } diff --git a/webpush/store.go b/webpush/store.go index 81800431..4fc5d77f 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -157,7 +157,7 @@ func (s *commonStore) RemoveExpiredSubscriptions(expireAfter time.Duration) erro } // SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is -// exported for testing purposes and is not part of the Store interface. +// exported for testing purposes. func (s *commonStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { _, err := s.db.Exec(s.queries.updateSubscriptionUpdatedAt, updatedAt, endpoint) return err diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 7fe4ba3f..a00c2e61 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -9,7 +9,6 @@ import ( const ( sqliteCreateWebPushSubscriptionsTableQuery = ` - BEGIN; CREATE TABLE IF NOT EXISTS subscription ( id TEXT PRIMARY KEY, endpoint TEXT NOT NULL, @@ -32,8 +31,7 @@ const ( CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL - ); - COMMIT; + ); ` sqliteBuiltinStartupQueries = ` PRAGMA foreign_keys = ON; @@ -122,13 +120,18 @@ func setupSQLite(db *sql.DB) error { } func setupNewSQLite(db *sql.DB) error { - if _, err := db.Exec(sqliteCreateWebPushSubscriptionsTableQuery); err != nil { + tx, err := db.Begin() + if err != nil { return err } - if _, err := db.Exec(sqliteInsertWebPushSchemaVersionQuery, sqliteCurrentWebPushSchemaVersion); err != nil { + defer tx.Rollback() + if _, err := tx.Exec(sqliteCreateWebPushSubscriptionsTableQuery); err != nil { return err } - return nil + if _, err := tx.Exec(sqliteInsertWebPushSchemaVersionQuery, sqliteCurrentWebPushSchemaVersion); err != nil { + return err + } + return tx.Commit() } func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { From b02366b42bda189f5382c947436852e262f6173a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Feb 2026 13:49:54 -0500 Subject: [PATCH 230/548] Make more consistent --- user/manager.go | 51 +++++++++---------------- user/store.go | 86 +++++++++++++++++------------------------- user/store_postgres.go | 7 +--- user/store_sqlite.go | 7 +--- user/store_test.go | 49 +++++++++++------------- 5 files changed, 78 insertions(+), 122 deletions(-) diff --git a/user/manager.go b/user/manager.go index 534ff86d..ce890317 100644 --- a/user/manager.go +++ b/user/manager.go @@ -116,24 +116,7 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { // after a fixed duration unless ChangeToken is called. This function also prunes tokens for the // given user, if there are too many of them. func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) { - token := GenerateToken() - access := time.Now() - // Create the token - createdToken, err := a.store.CreateToken(userID, token, label, access, origin, expires, provisioned) - if err != nil { - return nil, err - } - // Check token count and prune if necessary - tokenCount, err := a.store.TokenCount(userID) - if err != nil { - return nil, err - } - if tokenCount >= tokenMaxCount { - if err := a.store.RemoveExcessTokens(userID, tokenMaxCount); err != nil { - return nil, err - } - } - return createdToken, nil + return a.store.CreateToken(userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned) } // Tokens returns all existing tokens for the user with the given user ID @@ -151,32 +134,35 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time if token == "" { return nil, errNoTokenProvided } - if err := a.CanChangeToken(userID, token); err != nil { + if err := a.canChangeToken(userID, token); err != nil { + return nil, err + } + t, err := a.store.Token(userID, token) + if err != nil { return nil, err } if label != nil { - if err := a.store.ChangeTokenLabel(userID, token, *label); err != nil { - return nil, err - } + t.Label = *label } if expires != nil { - if err := a.store.ChangeTokenExpiry(userID, token, *expires); err != nil { - return nil, err - } + t.Expires = *expires } - return a.Token(userID, token) + if err := a.store.ChangeToken(userID, token, t.Label, t.Expires); err != nil { + return nil, err + } + return t, nil } // RemoveToken deletes the token defined in User.Token func (a *Manager) RemoveToken(userID, token string) error { - if err := a.CanChangeToken(userID, token); err != nil { + if err := a.canChangeToken(userID, token); err != nil { return err } return a.store.RemoveToken(userID, token) } -// CanChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed. -func (a *Manager) CanChangeToken(userID, token string) error { +// canChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed. +func (a *Manager) canChangeToken(userID, token string) error { t, err := a.Token(userID, token) if err != nil { return err @@ -277,11 +263,8 @@ func (a *Manager) writeUserStatsQueue() error { "calls_count": update.Calls, }). Trace("Updating stats for user %s", userID) - if err := a.store.UpdateStats(userID, update); err != nil { - return err - } } - return nil + return a.store.UpdateStats(statsQueue) } func (a *Manager) writeTokenUpdateQueue() error { @@ -778,7 +761,7 @@ func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { return fmt.Errorf("failed to find provisioned user %s for provisioned tokens: %v", username, err) } for _, token := range tokens { - if _, err := a.store.CreateToken(userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), true); err != nil { + if _, err := a.store.CreateToken(userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), 0, true); err != nil { return err } } diff --git a/user/store.go b/user/store.go index 0784ae42..f03c883e 100644 --- a/user/store.go +++ b/user/store.go @@ -32,22 +32,19 @@ type Store interface { ChangeSettings(userID string, prefs *Prefs) error ChangeTier(username, tierCode string) error ResetTier(username string) error - UpdateStats(userID string, stats *Stats) error + UpdateStats(stats map[string]*Stats) error ResetStats() error // Token operations - CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, provisioned bool) (*Token, error) + CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) Token(userID, token string) (*Token, error) Tokens(userID string) ([]*Token, error) AllProvisionedTokens() ([]*Token, error) - ChangeTokenLabel(userID, token, label string) error - ChangeTokenExpiry(userID, token string, expires time.Time) error + ChangeToken(userID, token, label string, expires time.Time) error UpdateTokenLastAccess(token string, lastAccess time.Time, lastOrigin netip.Addr) error RemoveToken(userID, token string) error RemoveProvisionedToken(token string) error RemoveExpiredTokens() error - TokenCount(userID string) (int, error) - RemoveExcessTokens(userID string, maxCount int) error // Access operations AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) @@ -119,11 +116,9 @@ type storeQueries struct { // Token queries selectToken string selectTokens string - selectTokenCount string selectAllProvisionedTokens string upsertToken string - updateTokenLabel string - updateTokenExpiry string + updateToken string updateTokenLastAccess string deleteToken string deleteProvisionedToken string @@ -366,12 +361,19 @@ func (s *commonStore) ResetTier(username string) error { return err } -// UpdateStats updates the user statistics -func (s *commonStore) UpdateStats(userID string, stats *Stats) error { - if _, err := s.db.Exec(s.queries.updateUserStats, stats.Messages, stats.Emails, stats.Calls, userID); err != nil { +// UpdateStats updates statistics for one or more users in a single transaction +func (s *commonStore) UpdateStats(stats map[string]*Stats) error { + tx, err := s.db.Begin() + if err != nil { return err } - return nil + defer tx.Rollback() + for userID, update := range stats { + if _, err := tx.Exec(s.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { + return err + } + } + return tx.Commit() } // ResetStats resets all user stats in the user database @@ -443,9 +445,23 @@ func (s *commonStore) readUser(rows *sql.Rows) (*User, error) { return user, nil } -// CreateToken creates a new token -func (s *commonStore) CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, provisioned bool) (*Token, error) { - if _, err := s.db.Exec(s.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil { +// CreateToken creates a new token and prunes excess tokens if the count exceeds maxTokenCount. +// If maxTokenCount is 0, no pruning is performed. +func (s *commonStore) CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err := tx.Exec(s.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil { + return nil, err + } + if maxTokenCount > 0 { + if _, err := tx.Exec(s.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { return nil, err } return &Token{ @@ -508,17 +524,9 @@ func (s *commonStore) AllProvisionedTokens() ([]*Token, error) { return tokens, nil } -// ChangeTokenLabel updates a token's label -func (s *commonStore) ChangeTokenLabel(userID, token, label string) error { - if _, err := s.db.Exec(s.queries.updateTokenLabel, label, userID, token); err != nil { - return err - } - return nil -} - -// ChangeTokenExpiry updates a token's expiry time -func (s *commonStore) ChangeTokenExpiry(userID, token string, expires time.Time) error { - if _, err := s.db.Exec(s.queries.updateTokenExpiry, expires.Unix(), userID, token); err != nil { +// ChangeToken updates a token's label and expiry time +func (s *commonStore) ChangeToken(userID, token, label string, expires time.Time) error { + if _, err := s.db.Exec(s.queries.updateToken, label, expires.Unix(), userID, token); err != nil { return err } return nil @@ -562,30 +570,6 @@ func (s *commonStore) RemoveExpiredTokens() error { return nil } -// TokenCount returns the number of tokens for a user -func (s *commonStore) TokenCount(userID string) (int, error) { - rows, err := s.db.Query(s.queries.selectTokenCount, userID) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - var count int - if err := rows.Scan(&count); err != nil { - return 0, err - } - return count, nil -} - -// RemoveExcessTokens deletes excess tokens beyond the specified maximum -func (s *commonStore) RemoveExcessTokens(userID string, maxCount int) error { - if _, err := s.db.Exec(s.queries.deleteExcessTokens, userID, userID, maxCount); err != nil { - return err - } - return nil -} func (s *commonStore) readToken(rows *sql.Rows) (*Token, error) { var token, label, lastOrigin string var lastAccess, expires int64 diff --git a/user/store_postgres.go b/user/store_postgres.go index 229bfd6d..c97f4800 100644 --- a/user/store_postgres.go +++ b/user/store_postgres.go @@ -146,8 +146,7 @@ const ( ON CONFLICT (user_id, token) DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned ` - postgresUpdateTokenLabelQuery = `UPDATE user_token SET label = $1 WHERE user_id = $2 AND token = $3` - postgresUpdateTokenExpiryQuery = `UPDATE user_token SET expires = $1 WHERE user_id = $2 AND token = $3` + postgresUpdateTokenQuery = `UPDATE user_token SET label = $1, expires = $2 WHERE user_id = $3 AND token = $4` postgresUpdateTokenLastAccessQuery = `UPDATE user_token SET last_access = $1, last_origin = $2 WHERE token = $3` postgresDeleteTokenQuery = `DELETE FROM user_token WHERE user_id = $1 AND token = $2` postgresDeleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = $1` @@ -251,11 +250,9 @@ func NewPostgresStore(db *sql.DB) (Store, error) { // Token queries selectToken: postgresSelectTokenQuery, selectTokens: postgresSelectTokensQuery, - selectTokenCount: postgresSelectTokenCountQuery, selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, upsertToken: postgresUpsertTokenQuery, - updateTokenLabel: postgresUpdateTokenLabelQuery, - updateTokenExpiry: postgresUpdateTokenExpiryQuery, + updateToken: postgresUpdateTokenQuery, updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, deleteToken: postgresDeleteTokenQuery, deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, diff --git a/user/store_sqlite.go b/user/store_sqlite.go index 57c61f0c..602b88d4 100644 --- a/user/store_sqlite.go +++ b/user/store_sqlite.go @@ -144,8 +144,7 @@ const ( ON CONFLICT (user_id, token) DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned ` - sqliteUpdateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` - sqliteUpdateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` + sqliteUpdateTokenQuery = `UPDATE user_token SET label = ?, expires = ? WHERE user_id = ? AND token = ?` sqliteUpdateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` sqliteDeleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` sqliteDeleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = ?` @@ -255,11 +254,9 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { deleteAllAccess: sqliteDeleteAllAccessQuery, selectToken: sqliteSelectTokenQuery, selectTokens: sqliteSelectTokensQuery, - selectTokenCount: sqliteSelectTokenCountQuery, selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, upsertToken: sqliteUpsertTokenQuery, - updateTokenLabel: sqliteUpdateTokenLabelQuery, - updateTokenExpiry: sqliteUpdateTokenExpiryQuery, + updateToken: sqliteUpdateTokenQuery, updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, deleteToken: sqliteDeleteTokenQuery, deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, diff --git a/user/store_test.go b/user/store_test.go index 7dc3ef38..1294cc86 100644 --- a/user/store_test.go +++ b/user/store_test.go @@ -78,7 +78,7 @@ func TestStoreUserByToken(t *testing.T) { u, err := store.User("phil") require.Nil(t, err) - tk, err := store.CreateToken(u.ID, "tk_test123", "test token", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(24*time.Hour), false) + tk, err := store.CreateToken(u.ID, "tk_test123", "test token", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(24*time.Hour), 0, false) require.Nil(t, err) require.Equal(t, "tk_test123", tk.Value) @@ -165,7 +165,7 @@ func TestStoreTokens(t *testing.T) { expires := now.Add(24 * time.Hour) origin := netip.MustParseAddr("9.9.9.9") - tk, err := store.CreateToken(u.ID, "tk_abc", "my token", now, origin, expires, false) + tk, err := store.CreateToken(u.ID, "tk_abc", "my token", now, origin, expires, 0, false) require.Nil(t, err) require.Equal(t, "tk_abc", tk.Value) require.Equal(t, "my token", tk.Label) @@ -181,27 +181,25 @@ func TestStoreTokens(t *testing.T) { require.Nil(t, err) require.Len(t, tokens, 1) require.Equal(t, "tk_abc", tokens[0].Value) - - // Token count - count, err := store.TokenCount(u.ID) - require.Nil(t, err) - require.Equal(t, 1, count) }) } -func TestStoreTokenChangeLabel(t *testing.T) { +func TestStoreTokenChange(t *testing.T) { forEachStoreBackend(t, func(t *testing.T, store user.Store) { require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) u, err := store.User("phil") require.Nil(t, err) - _, err = store.CreateToken(u.ID, "tk_abc", "old label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + expires := time.Now().Add(time.Hour) + _, err = store.CreateToken(u.ID, "tk_abc", "old label", time.Now(), netip.MustParseAddr("1.2.3.4"), expires, 0, false) require.Nil(t, err) - require.Nil(t, store.ChangeTokenLabel(u.ID, "tk_abc", "new label")) + newExpires := time.Now().Add(2 * time.Hour) + require.Nil(t, store.ChangeToken(u.ID, "tk_abc", "new label", newExpires)) tk, err := store.Token(u.ID, "tk_abc") require.Nil(t, err) require.Equal(t, "new label", tk.Label) + require.Equal(t, newExpires.Unix(), tk.Expires.Unix()) }) } @@ -211,7 +209,7 @@ func TestStoreTokenRemove(t *testing.T) { u, err := store.User("phil") require.Nil(t, err) - _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false) require.Nil(t, err) require.Nil(t, store.RemoveToken(u.ID, "tk_abc")) @@ -227,9 +225,9 @@ func TestStoreTokenRemoveExpired(t *testing.T) { require.Nil(t, err) // Create expired token and active token - _, err = store.CreateToken(u.ID, "tk_expired", "expired", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(-time.Hour), false) + _, err = store.CreateToken(u.ID, "tk_expired", "expired", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(-time.Hour), 0, false) require.Nil(t, err) - _, err = store.CreateToken(u.ID, "tk_active", "active", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + _, err = store.CreateToken(u.ID, "tk_active", "active", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false) require.Nil(t, err) require.Nil(t, store.RemoveExpiredTokens()) @@ -245,28 +243,25 @@ func TestStoreTokenRemoveExpired(t *testing.T) { }) } -func TestStoreTokenRemoveExcess(t *testing.T) { +func TestStoreTokenCreatePrunesExcess(t *testing.T) { forEachStoreBackend(t, func(t *testing.T, store user.Store) { require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) u, err := store.User("phil") require.Nil(t, err) - // Create 3 tokens with increasing expiry - for i, name := range []string{"tk_a", "tk_b", "tk_c"} { - _, err = store.CreateToken(u.ID, name, name, time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Duration(i+1)*time.Hour), false) + // Create 2 tokens with no pruning + for i, name := range []string{"tk_a", "tk_b"} { + _, err = store.CreateToken(u.ID, name, name, time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Duration(i+1)*time.Hour), 0, false) require.Nil(t, err) } - count, err := store.TokenCount(u.ID) + // Create a 3rd token with maxTokenCount=2, which should prune tk_a (earliest expiry) + _, err = store.CreateToken(u.ID, "tk_c", "tk_c", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(3*time.Hour), 2, false) require.Nil(t, err) - require.Equal(t, 3, count) - // Remove excess, keep only 2 (the ones with latest expiry: tk_b, tk_c) - require.Nil(t, store.RemoveExcessTokens(u.ID, 2)) - - count, err = store.TokenCount(u.ID) + tokens, err := store.Tokens(u.ID) require.Nil(t, err) - require.Equal(t, 2, count) + require.Equal(t, 2, len(tokens)) // tk_a should be removed (earliest expiry) _, err = store.Token(u.ID, "tk_a") @@ -286,7 +281,7 @@ func TestStoreTokenUpdateLastAccess(t *testing.T) { u, err := store.User("phil") require.Nil(t, err) - _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false) + _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false) require.Nil(t, err) newTime := time.Now().Add(5 * time.Minute) @@ -628,7 +623,7 @@ func TestStoreUpdateStats(t *testing.T) { require.Nil(t, err) stats := &user.Stats{Messages: 42, Emails: 3, Calls: 1} - require.Nil(t, store.UpdateStats(u.ID, stats)) + require.Nil(t, store.UpdateStats(map[string]*user.Stats{u.ID: stats})) u2, err := store.User("phil") require.Nil(t, err) @@ -644,7 +639,7 @@ func TestStoreResetStats(t *testing.T) { u, err := store.User("phil") require.Nil(t, err) - require.Nil(t, store.UpdateStats(u.ID, &user.Stats{Messages: 42, Emails: 3, Calls: 1})) + require.Nil(t, store.UpdateStats(map[string]*user.Stats{u.ID: {Messages: 42, Emails: 3, Calls: 1}})) require.Nil(t, store.ResetStats()) u2, err := store.User("phil") From 28a436c0d2eda18f4e6e68afbfc2cc23e91dd27c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Feb 2026 14:11:36 -0500 Subject: [PATCH 231/548] Optimizations --- user/manager.go | 5 +---- user/manager_test.go | 2 +- user/store.go | 26 ++++++++++++++++++++------ user/store_postgres.go | 1 + user/store_sqlite.go | 1 + user/store_test.go | 2 +- 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/user/manager.go b/user/manager.go index ce890317..2a962be2 100644 --- a/user/manager.go +++ b/user/manager.go @@ -281,11 +281,8 @@ func (a *Manager) writeTokenUpdateQueue() error { log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) for tokenID, update := range tokenQueue { log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) - if err := a.store.UpdateTokenLastAccess(tokenID, update.LastAccess, update.LastOrigin); err != nil { - return err - } } - return nil + return a.store.UpdateTokenLastAccess(tokenQueue) } // Authorize returns nil if the given user has access to the given topic using the desired diff --git a/user/manager_test.go b/user/manager_test.go index b1858ad4..ea976a3f 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1218,7 +1218,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Update the token last access time and origin (so we can check that it is persisted) lastAccessTime := time.Now().Add(time.Hour) lastOrigin := netip.MustParseAddr("1.1.9.9") - err = a.store.UpdateTokenLastAccess(tokens[0].Value, lastAccessTime, lastOrigin) + err = a.store.UpdateTokenLastAccess(map[string]*TokenUpdate{tokens[0].Value: {LastAccess: lastAccessTime, LastOrigin: lastOrigin}}) require.Nil(t, err) // Re-open the DB (second app start) diff --git a/user/store.go b/user/store.go index f03c883e..fd9a3fbb 100644 --- a/user/store.go +++ b/user/store.go @@ -41,7 +41,7 @@ type Store interface { Tokens(userID string) ([]*Token, error) AllProvisionedTokens() ([]*Token, error) ChangeToken(userID, token, label string, expires time.Time) error - UpdateTokenLastAccess(token string, lastAccess time.Time, lastOrigin netip.Addr) error + UpdateTokenLastAccess(updates map[string]*TokenUpdate) error RemoveToken(userID, token string) error RemoveProvisionedToken(token string) error RemoveExpiredTokens() error @@ -116,6 +116,7 @@ type storeQueries struct { // Token queries selectToken string selectTokens string + selectTokenCount string selectAllProvisionedTokens string upsertToken string updateToken string @@ -457,9 +458,15 @@ func (s *commonStore) CreateToken(userID, token, label string, lastAccess time.T return nil, err } if maxTokenCount > 0 { - if _, err := tx.Exec(s.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil { + var tokenCount int + if err := tx.QueryRow(s.queries.selectTokenCount, userID).Scan(&tokenCount); err != nil { return nil, err } + if tokenCount > maxTokenCount { + if _, err := tx.Exec(s.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil { + return nil, err + } + } } if err := tx.Commit(); err != nil { return nil, err @@ -532,12 +539,19 @@ func (s *commonStore) ChangeToken(userID, token, label string, expires time.Time return nil } -// UpdateTokenLastAccess updates a token's last access time and origin -func (s *commonStore) UpdateTokenLastAccess(token string, lastAccess time.Time, lastOrigin netip.Addr) error { - if _, err := s.db.Exec(s.queries.updateTokenLastAccess, lastAccess.Unix(), lastOrigin.String(), token); err != nil { +// UpdateTokenLastAccess updates the last access time and origin for one or more tokens in a single transaction +func (s *commonStore) UpdateTokenLastAccess(updates map[string]*TokenUpdate) error { + tx, err := s.db.Begin() + if err != nil { return err } - return nil + defer tx.Rollback() + for token, update := range updates { + if _, err := tx.Exec(s.queries.updateTokenLastAccess, update.LastAccess.Unix(), update.LastOrigin.String(), token); err != nil { + return err + } + } + return tx.Commit() } // RemoveToken deletes the token diff --git a/user/store_postgres.go b/user/store_postgres.go index c97f4800..c4c9577c 100644 --- a/user/store_postgres.go +++ b/user/store_postgres.go @@ -250,6 +250,7 @@ func NewPostgresStore(db *sql.DB) (Store, error) { // Token queries selectToken: postgresSelectTokenQuery, selectTokens: postgresSelectTokensQuery, + selectTokenCount: postgresSelectTokenCountQuery, selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, upsertToken: postgresUpsertTokenQuery, updateToken: postgresUpdateTokenQuery, diff --git a/user/store_sqlite.go b/user/store_sqlite.go index 602b88d4..c9d6d33f 100644 --- a/user/store_sqlite.go +++ b/user/store_sqlite.go @@ -254,6 +254,7 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { deleteAllAccess: sqliteDeleteAllAccessQuery, selectToken: sqliteSelectTokenQuery, selectTokens: sqliteSelectTokensQuery, + selectTokenCount: sqliteSelectTokenCountQuery, selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, upsertToken: sqliteUpsertTokenQuery, updateToken: sqliteUpdateTokenQuery, diff --git a/user/store_test.go b/user/store_test.go index 1294cc86..c5c40a45 100644 --- a/user/store_test.go +++ b/user/store_test.go @@ -286,7 +286,7 @@ func TestStoreTokenUpdateLastAccess(t *testing.T) { newTime := time.Now().Add(5 * time.Minute) newOrigin := netip.MustParseAddr("5.5.5.5") - require.Nil(t, store.UpdateTokenLastAccess("tk_abc", newTime, newOrigin)) + require.Nil(t, store.UpdateTokenLastAccess(map[string]*user.TokenUpdate{"tk_abc": {LastAccess: newTime, LastOrigin: newOrigin}})) tk, err := store.Token(u.ID, "tk_abc") require.Nil(t, err) From 9eec72adcc1e7fde224e63066adda64d11327526 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Feb 2026 21:39:07 -0500 Subject: [PATCH 232/548] Fix race tests --- server/server_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/server_test.go b/server/server_test.go index 357f1138..1f206562 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2677,10 +2677,14 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { require.True(t, time.Since(start) < 100*time.Millisecond) log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) - // Wait for all goroutines + // Wait for all Goroutines + waitDuration := 10 * time.Second + if raceEnabled { + waitDuration = 35 * time.Second + } select { case <-statsChan: - case <-time.After(10 * time.Second): + case <-time.After(waitDuration): t.Fatal("Timed out waiting for Go routines") } log.Info("Done: Waiting for all locks") From 391cd2c920e42c6bd0a211dbfc7c69c864cabc6d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Feb 2026 22:44:21 -0500 Subject: [PATCH 233/548] Migration tool --- server/server_race_off_test.go | 5 + server/server_race_on_test.go | 5 + tools/pgimport/main.go | 709 +++++++++++++++++++++++++++++++++ 3 files changed, 719 insertions(+) create mode 100644 server/server_race_off_test.go create mode 100644 server/server_race_on_test.go create mode 100644 tools/pgimport/main.go diff --git a/server/server_race_off_test.go b/server/server_race_off_test.go new file mode 100644 index 00000000..1ce58819 --- /dev/null +++ b/server/server_race_off_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package server + +const raceEnabled = false diff --git a/server/server_race_on_test.go b/server/server_race_on_test.go new file mode 100644 index 00000000..30e30e14 --- /dev/null +++ b/server/server_race_on_test.go @@ -0,0 +1,5 @@ +//go:build race + +package server + +const raceEnabled = true diff --git a/tools/pgimport/main.go b/tools/pgimport/main.go new file mode 100644 index 00000000..fb91da27 --- /dev/null +++ b/tools/pgimport/main.go @@ -0,0 +1,709 @@ +package main + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "strings" + + _ "github.com/mattn/go-sqlite3" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "gopkg.in/yaml.v2" + "heckel.io/ntfy/v2/db" +) + +const ( + batchSize = 1000 + + expectedMessageSchemaVersion = 14 + expectedUserSchemaVersion = 6 + expectedWebPushSchemaVersion = 1 +) + +var flags = []cli.Flag{ + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "path to server.yml config file"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, Usage: "PostgreSQL connection string"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file"}, Usage: "SQLite message cache file path"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file"}, Usage: "SQLite user/auth database file path"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, Usage: "SQLite web push database file path"}), +} + +func main() { + app := &cli.App{ + Name: "pgimport", + Usage: "SQLite to PostgreSQL migration tool for ntfy", + UsageText: "pgimport [OPTIONS]", + Flags: flags, + Before: loadConfigFile("config", flags), + Action: execImport, + } + if err := app.Run(os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func execImport(c *cli.Context) error { + databaseURL := c.String("database-url") + cacheFile := c.String("cache-file") + authFile := c.String("auth-file") + webPushFile := c.String("web-push-file") + + if databaseURL == "" { + return fmt.Errorf("database-url must be set (via --database-url or config file)") + } + if cacheFile == "" && authFile == "" && webPushFile == "" { + return fmt.Errorf("at least one of --cache-file, --auth-file, or --web-push-file must be set") + } + + fmt.Println("pgimport - SQLite to PostgreSQL migration tool for ntfy") + fmt.Println() + fmt.Println("Sources:") + printSource(" Cache file: ", cacheFile) + printSource(" Auth file: ", authFile) + printSource(" Web push file: ", webPushFile) + fmt.Println() + fmt.Println("Target:") + fmt.Printf(" Database URL: %s\n", maskPassword(databaseURL)) + fmt.Println() + fmt.Println("This will import data from the SQLite databases into PostgreSQL.") + fmt.Print("Make sure ntfy is not running. Continue? (y/n): ") + + var answer string + fmt.Scanln(&answer) + if strings.TrimSpace(strings.ToLower(answer)) != "y" { + fmt.Println("Aborted.") + return nil + } + fmt.Println() + + pgDB, err := db.OpenPostgres(databaseURL) + if err != nil { + return fmt.Errorf("cannot connect to PostgreSQL: %w", err) + } + defer pgDB.Close() + + if authFile != "" { + if err := verifySchemaVersion(pgDB, "user", expectedUserSchemaVersion); err != nil { + return err + } + if err := importUsers(authFile, pgDB); err != nil { + return fmt.Errorf("cannot import users: %w", err) + } + } + if cacheFile != "" { + if err := verifySchemaVersion(pgDB, "message", expectedMessageSchemaVersion); err != nil { + return err + } + if err := importMessages(cacheFile, pgDB); err != nil { + return fmt.Errorf("cannot import messages: %w", err) + } + } + if webPushFile != "" { + if err := verifySchemaVersion(pgDB, "webpush", expectedWebPushSchemaVersion); err != nil { + return err + } + if err := importWebPush(webPushFile, pgDB); err != nil { + return fmt.Errorf("cannot import web push subscriptions: %w", err) + } + } + + fmt.Println() + fmt.Println("Verifying migration ...") + failed := false + if authFile != "" { + if err := verifyUsers(authFile, pgDB, &failed); err != nil { + return fmt.Errorf("cannot verify users: %w", err) + } + } + if cacheFile != "" { + if err := verifyMessages(cacheFile, pgDB, &failed); err != nil { + return fmt.Errorf("cannot verify messages: %w", err) + } + } + if webPushFile != "" { + if err := verifyWebPush(webPushFile, pgDB, &failed); err != nil { + return fmt.Errorf("cannot verify web push: %w", err) + } + } + fmt.Println() + if failed { + return fmt.Errorf("verification FAILED, see above for details") + } + fmt.Println("Verification successful. Migration complete.") + return nil +} + +func loadConfigFile(configFlag string, flags []cli.Flag) cli.BeforeFunc { + return func(c *cli.Context) error { + configFile := c.String(configFlag) + if configFile == "" { + return nil + } + if _, err := os.Stat(configFile); os.IsNotExist(err) { + return fmt.Errorf("config file %s does not exist", configFile) + } + inputSource, err := newYamlSourceFromFile(configFile, flags) + if err != nil { + return err + } + return altsrc.ApplyInputSourceValues(c, inputSource, flags) + } +} + +func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) { + var rawConfig map[any]any + b, err := os.ReadFile(file) + if err != nil { + return nil, err + } + if err := yaml.Unmarshal(b, &rawConfig); err != nil { + return nil, err + } + for _, f := range flags { + flagName := f.Names()[0] + for _, flagAlias := range f.Names()[1:] { + if _, ok := rawConfig[flagAlias]; ok { + rawConfig[flagName] = rawConfig[flagAlias] + } + } + } + return altsrc.NewMapInputSource(file, rawConfig), nil +} + +func verifySchemaVersion(pgDB *sql.DB, store string, expected int) error { + var version int + err := pgDB.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version) + if err != nil { + return fmt.Errorf("cannot read %s schema version from PostgreSQL (is the schema set up?): %w", store, err) + } + if version != expected { + return fmt.Errorf("%s schema version mismatch: expected %d, got %d", store, expected, version) + } + return nil +} + +func printSource(label, path string) { + if path == "" { + fmt.Printf("%s(not set, skipping)\n", label) + } else if _, err := os.Stat(path); os.IsNotExist(err) { + fmt.Printf("%s%s (NOT FOUND, skipping)\n", label, path) + } else { + fmt.Printf("%s%s\n", label, path) + } +} + +func maskPassword(databaseURL string) string { + u, err := url.Parse(databaseURL) + if err != nil { + return databaseURL + } + if u.User != nil { + if _, hasPass := u.User.Password(); hasPass { + masked := u.Scheme + "://" + u.User.Username() + ":****@" + u.Host + u.Path + if u.RawQuery != "" { + masked += "?" + u.RawQuery + } + return masked + } + } + return u.String() +} + +func openSQLite(filename string) (*sql.DB, error) { + if _, err := os.Stat(filename); os.IsNotExist(err) { + return nil, fmt.Errorf("file %s does not exist", filename) + } + return sql.Open("sqlite3", filename+"?mode=ro") +} + +// User import + +func importUsers(sqliteFile string, pgDB *sql.DB) error { + sqlDB, err := openSQLite(sqliteFile) + if err != nil { + fmt.Printf("Skipping user import: %s\n", err) + return nil + } + defer sqlDB.Close() + fmt.Printf("Importing users from %s ...\n", sqliteFile) + + count, err := importTiers(sqlDB, pgDB) + if err != nil { + return fmt.Errorf("importing tiers: %w", err) + } + fmt.Printf(" Imported %d tiers\n", count) + + count, err = importUserRows(sqlDB, pgDB) + if err != nil { + return fmt.Errorf("importing users: %w", err) + } + fmt.Printf(" Imported %d users\n", count) + + count, err = importUserAccess(sqlDB, pgDB) + if err != nil { + return fmt.Errorf("importing user access: %w", err) + } + fmt.Printf(" Imported %d access entries\n", count) + + count, err = importUserTokens(sqlDB, pgDB) + if err != nil { + return fmt.Errorf("importing user tokens: %w", err) + } + fmt.Printf(" Imported %d tokens\n", count) + + count, err = importUserPhones(sqlDB, pgDB) + if err != nil { + return fmt.Errorf("importing user phones: %w", err) + } + fmt.Printf(" Imported %d phone numbers\n", count) + + return nil +} + +func importTiers(sqlDB, pgDB *sql.DB) (int, error) { + rows, err := sqlDB.Query(`SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier`) + if err != nil { + return 0, err + } + defer rows.Close() + + tx, err := pgDB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (id) DO NOTHING`) + if err != nil { + return 0, err + } + defer stmt.Close() + + count := 0 + for rows.Next() { + var id, code, name string + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit int64 + var attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit int64 + var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString + if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + return 0, err + } + if _, err := stmt.Exec(id, code, name, messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeMonthlyPriceID, stripeYearlyPriceID); err != nil { + return 0, err + } + count++ + } + return count, tx.Commit() +} + +func importUserRows(sqlDB, pgDB *sql.DB) (int, error) { + rows, err := sqlDB.Query(`SELECT id, user, pass, role, prefs, sync_topic, provisioned, stats_messages, stats_emails, stats_calls, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_subscription_interval, stripe_subscription_paid_until, stripe_subscription_cancel_at, created, deleted, tier_id FROM user`) + if err != nil { + return 0, err + } + defer rows.Close() + + tx, err := pgDB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(` + INSERT INTO "user" (id, user_name, pass, role, prefs, sync_topic, provisioned, stats_messages, stats_emails, stats_calls, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_subscription_interval, stripe_subscription_paid_until, stripe_subscription_cancel_at, created, deleted, tier_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (id) DO NOTHING + `) + if err != nil { + return 0, err + } + defer stmt.Close() + + count := 0 + for rows.Next() { + var id, userName, pass, role, prefs, syncTopic string + var provisioned int + var statsMessages, statsEmails, statsCalls int64 + var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval sql.NullString + var stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt sql.NullInt64 + var created int64 + var deleted sql.NullInt64 + var tierID sql.NullString + if err := rows.Scan(&id, &userName, &pass, &role, &prefs, &syncTopic, &provisioned, &statsMessages, &statsEmails, &statsCalls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &created, &deleted, &tierID); err != nil { + return 0, err + } + provisionedBool := provisioned != 0 + if _, err := stmt.Exec(id, userName, pass, role, prefs, syncTopic, provisionedBool, statsMessages, statsEmails, statsCalls, stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, created, deleted, tierID); err != nil { + return 0, err + } + count++ + } + return count, tx.Commit() +} + +func importUserAccess(sqlDB, pgDB *sql.DB) (int, error) { + rows, err := sqlDB.Query(`SELECT a.user_id, a.topic, a.read, a.write, a.owner_user_id, a.provisioned FROM user_access a JOIN user u ON u.id = a.user_id`) + if err != nil { + return 0, err + } + defer rows.Close() + + tx, err := pgDB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (user_id, topic) DO NOTHING`) + if err != nil { + return 0, err + } + defer stmt.Close() + + count := 0 + for rows.Next() { + var userID, topic string + var read, write, provisioned int + var ownerUserID sql.NullString + if err := rows.Scan(&userID, &topic, &read, &write, &ownerUserID, &provisioned); err != nil { + return 0, err + } + readBool := read != 0 + writeBool := write != 0 + provisionedBool := provisioned != 0 + if _, err := stmt.Exec(userID, topic, readBool, writeBool, ownerUserID, provisionedBool); err != nil { + return 0, err + } + count++ + } + return count, tx.Commit() +} + +func importUserTokens(sqlDB, pgDB *sql.DB) (int, error) { + rows, err := sqlDB.Query(`SELECT t.user_id, t.token, t.label, t.last_access, t.last_origin, t.expires, t.provisioned FROM user_token t JOIN user u ON u.id = t.user_id`) + if err != nil { + return 0, err + } + defer rows.Close() + + tx, err := pgDB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, token) DO NOTHING`) + if err != nil { + return 0, err + } + defer stmt.Close() + + count := 0 + for rows.Next() { + var userID, token, label, lastOrigin string + var lastAccess, expires int64 + var provisioned int + if err := rows.Scan(&userID, &token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil { + return 0, err + } + provisionedBool := provisioned != 0 + if _, err := stmt.Exec(userID, token, label, lastAccess, lastOrigin, expires, provisionedBool); err != nil { + return 0, err + } + count++ + } + return count, tx.Commit() +} + +func importUserPhones(sqlDB, pgDB *sql.DB) (int, error) { + rows, err := sqlDB.Query(`SELECT p.user_id, p.phone_number FROM user_phone p JOIN user u ON u.id = p.user_id`) + if err != nil { + return 0, err + } + defer rows.Close() + + tx, err := pgDB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2) ON CONFLICT (user_id, phone_number) DO NOTHING`) + if err != nil { + return 0, err + } + defer stmt.Close() + + count := 0 + for rows.Next() { + var userID, phoneNumber string + if err := rows.Scan(&userID, &phoneNumber); err != nil { + return 0, err + } + if _, err := stmt.Exec(userID, phoneNumber); err != nil { + return 0, err + } + count++ + } + return count, tx.Commit() +} + +// Message import + +func importMessages(sqliteFile string, pgDB *sql.DB) error { + sqlDB, err := openSQLite(sqliteFile) + if err != nil { + fmt.Printf("Skipping message import: %s\n", err) + return nil + } + defer sqlDB.Close() + fmt.Printf("Importing messages from %s ...\n", sqliteFile) + + rows, err := sqlDB.Query(`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, attachment_deleted, sender, user, content_type, encoding, published FROM messages`) + if err != nil { + return fmt.Errorf("querying messages: %w", err) + } + defer rows.Close() + + if _, err := pgDB.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_message_mid_unique ON message (mid)`); err != nil { + return fmt.Errorf("creating unique index on mid: %w", err) + } + + insertQuery := `INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) ON CONFLICT (mid) DO NOTHING` + + count := 0 + batchCount := 0 + tx, err := pgDB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(insertQuery) + if err != nil { + return err + } + defer stmt.Close() + + for rows.Next() { + var mid, sequenceID, event, topic, message, title, tags, click, icon, actions string + var attachmentName, attachmentType, attachmentURL, sender, userID, contentType, encoding string + var msgTime, expires, attachmentExpires int64 + var priority int + var attachmentSize int64 + var attachmentDeleted, published int + if err := rows.Scan(&mid, &sequenceID, &msgTime, &event, &expires, &topic, &message, &title, &priority, &tags, &click, &icon, &actions, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL, &attachmentDeleted, &sender, &userID, &contentType, &encoding, &published); err != nil { + return fmt.Errorf("scanning message: %w", err) + } + mid = toUTF8(mid) + sequenceID = toUTF8(sequenceID) + event = toUTF8(event) + topic = toUTF8(topic) + message = toUTF8(message) + title = toUTF8(title) + tags = toUTF8(tags) + click = toUTF8(click) + icon = toUTF8(icon) + actions = toUTF8(actions) + attachmentName = toUTF8(attachmentName) + attachmentType = toUTF8(attachmentType) + attachmentURL = toUTF8(attachmentURL) + sender = toUTF8(sender) + userID = toUTF8(userID) + contentType = toUTF8(contentType) + encoding = toUTF8(encoding) + attachmentDeletedBool := attachmentDeleted != 0 + publishedBool := published != 0 + if _, err := stmt.Exec(mid, sequenceID, msgTime, event, expires, topic, message, title, priority, tags, click, icon, actions, attachmentName, attachmentType, attachmentSize, attachmentExpires, attachmentURL, attachmentDeletedBool, sender, userID, contentType, encoding, publishedBool); err != nil { + return fmt.Errorf("inserting message: %w", err) + } + count++ + batchCount++ + if batchCount >= batchSize { + stmt.Close() + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing message batch: %w", err) + } + fmt.Printf(" ... %d messages\n", count) + tx, err = pgDB.Begin() + if err != nil { + return err + } + stmt, err = tx.Prepare(insertQuery) + if err != nil { + return err + } + batchCount = 0 + } + } + if batchCount > 0 { + stmt.Close() + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing final message batch: %w", err) + } + } + fmt.Printf(" Imported %d messages\n", count) + + var statsValue int64 + err = sqlDB.QueryRow(`SELECT value FROM stats WHERE key = 'messages'`).Scan(&statsValue) + if err == nil { + if _, err := pgDB.Exec(`UPDATE message_stats SET value = $1 WHERE key = 'messages'`, statsValue); err != nil { + return fmt.Errorf("updating message stats: %w", err) + } + fmt.Printf(" Updated message stats (count: %d)\n", statsValue) + } + + return nil +} + +// Web push import + +func importWebPush(sqliteFile string, pgDB *sql.DB) error { + sqlDB, err := openSQLite(sqliteFile) + if err != nil { + fmt.Printf("Skipping web push import: %s\n", err) + return nil + } + defer sqlDB.Close() + fmt.Printf("Importing web push subscriptions from %s ...\n", sqliteFile) + + rows, err := sqlDB.Query(`SELECT id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at FROM subscription`) + if err != nil { + return fmt.Errorf("querying subscriptions: %w", err) + } + defer rows.Close() + + tx, err := pgDB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO NOTHING`) + if err != nil { + return err + } + defer stmt.Close() + + count := 0 + for rows.Next() { + var id, endpoint, keyAuth, keyP256dh, userID, subscriberIP string + var updatedAt, warnedAt int64 + if err := rows.Scan(&id, &endpoint, &keyAuth, &keyP256dh, &userID, &subscriberIP, &updatedAt, &warnedAt); err != nil { + return fmt.Errorf("scanning subscription: %w", err) + } + if _, err := stmt.Exec(id, endpoint, keyAuth, keyP256dh, userID, subscriberIP, updatedAt, warnedAt); err != nil { + return fmt.Errorf("inserting subscription: %w", err) + } + count++ + } + stmt.Close() + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing subscriptions: %w", err) + } + fmt.Printf(" Imported %d subscriptions\n", count) + + topicRows, err := sqlDB.Query(`SELECT subscription_id, topic FROM subscription_topic`) + if err != nil { + return fmt.Errorf("querying subscription topics: %w", err) + } + defer topicRows.Close() + + tx, err = pgDB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err = tx.Prepare(`INSERT INTO webpush_subscription_topic (subscription_id, topic) VALUES ($1, $2) ON CONFLICT (subscription_id, topic) DO NOTHING`) + if err != nil { + return err + } + defer stmt.Close() + + topicCount := 0 + for topicRows.Next() { + var subscriptionID, topic string + if err := topicRows.Scan(&subscriptionID, &topic); err != nil { + return fmt.Errorf("scanning subscription topic: %w", err) + } + if _, err := stmt.Exec(subscriptionID, topic); err != nil { + return fmt.Errorf("inserting subscription topic: %w", err) + } + topicCount++ + } + stmt.Close() + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing subscription topics: %w", err) + } + fmt.Printf(" Imported %d subscription topics\n", topicCount) + + return nil +} + +func toUTF8(s string) string { + return strings.ToValidUTF8(s, "\uFFFD") +} + +// Verification + +func verifyUsers(sqliteFile string, pgDB *sql.DB, failed *bool) error { + sqlDB, err := openSQLite(sqliteFile) + if err != nil { + return nil + } + defer sqlDB.Close() + + verifyCount(sqlDB, pgDB, "tier", `SELECT COUNT(*) FROM tier`, `SELECT COUNT(*) FROM tier`, failed) + verifyCount(sqlDB, pgDB, "user", `SELECT COUNT(*) FROM user`, `SELECT COUNT(*) FROM "user"`, failed) + verifyCount(sqlDB, pgDB, "user_access", `SELECT COUNT(*) FROM user_access a JOIN user u ON u.id = a.user_id`, `SELECT COUNT(*) FROM user_access`, failed) + verifyCount(sqlDB, pgDB, "user_token", `SELECT COUNT(*) FROM user_token t JOIN user u ON u.id = t.user_id`, `SELECT COUNT(*) FROM user_token`, failed) + verifyCount(sqlDB, pgDB, "user_phone", `SELECT COUNT(*) FROM user_phone p JOIN user u ON u.id = p.user_id`, `SELECT COUNT(*) FROM user_phone`, failed) + return nil +} + +func verifyMessages(sqliteFile string, pgDB *sql.DB, failed *bool) error { + sqlDB, err := openSQLite(sqliteFile) + if err != nil { + return nil + } + defer sqlDB.Close() + + verifyCount(sqlDB, pgDB, "messages", `SELECT COUNT(*) FROM messages`, `SELECT COUNT(*) FROM message`, failed) + return nil +} + +func verifyWebPush(sqliteFile string, pgDB *sql.DB, failed *bool) error { + sqlDB, err := openSQLite(sqliteFile) + if err != nil { + return nil + } + defer sqlDB.Close() + + verifyCount(sqlDB, pgDB, "subscription", `SELECT COUNT(*) FROM subscription`, `SELECT COUNT(*) FROM webpush_subscription`, failed) + verifyCount(sqlDB, pgDB, "subscription_topic", `SELECT COUNT(*) FROM subscription_topic`, `SELECT COUNT(*) FROM webpush_subscription_topic`, failed) + return nil +} + +func verifyCount(sqlDB, pgDB *sql.DB, table, sqliteQuery, pgQuery string, failed *bool) { + var sqliteCount, pgCount int64 + if err := sqlDB.QueryRow(sqliteQuery).Scan(&sqliteCount); err != nil { + fmt.Printf(" %-20s ERROR reading SQLite: %s\n", table, err) + *failed = true + return + } + if err := pgDB.QueryRow(pgQuery).Scan(&pgCount); err != nil { + fmt.Printf(" %-20s ERROR reading PostgreSQL: %s\n", table, err) + *failed = true + return + } + if sqliteCount == pgCount { + fmt.Printf(" %-20s OK (%d rows)\n", table, pgCount) + } else { + fmt.Printf(" %-20s MISMATCH: SQLite=%d, PostgreSQL=%d\n", table, sqliteCount, pgCount) + *failed = true + } +} From a7d5a9c5d8fb9e0395d25aa155c290b7abab5812 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Feb 2026 23:08:13 -0500 Subject: [PATCH 234/548] Sample --- tools/pgimport/README.md | 35 ++++++++ tools/pgimport/main.go | 187 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 tools/pgimport/README.md diff --git a/tools/pgimport/README.md b/tools/pgimport/README.md new file mode 100644 index 00000000..2c645405 --- /dev/null +++ b/tools/pgimport/README.md @@ -0,0 +1,35 @@ +# pgimport + +Migrates ntfy data from SQLite to PostgreSQL. + +## Build + +```bash +go build -o pgimport ./tools/pgimport/ +``` + +## Usage + +```bash +# Using CLI flags +pgimport \ + --database-url "postgres://user:pass@host:5432/ntfy?sslmode=require" \ + --cache-file /var/cache/ntfy/cache.db \ + --auth-file /var/lib/ntfy/user.db \ + --web-push-file /var/lib/ntfy/webpush.db + +# Using server.yml (flags override config values) +pgimport --config /etc/ntfy/server.yml +``` + +## Prerequisites + +- PostgreSQL schema must already be set up (run ntfy with `database-url` once) +- ntfy must not be running during the import +- All three SQLite files are optional; only the ones specified will be imported + +## Notes + +- The tool is idempotent and safe to re-run +- After importing, row counts and content are verified against the SQLite sources +- Invalid UTF-8 in messages is replaced with the Unicode replacement character diff --git a/tools/pgimport/main.go b/tools/pgimport/main.go index fb91da27..9637d33d 100644 --- a/tools/pgimport/main.go +++ b/tools/pgimport/main.go @@ -658,10 +658,35 @@ func verifyUsers(sqliteFile string, pgDB *sql.DB, failed *bool) error { defer sqlDB.Close() verifyCount(sqlDB, pgDB, "tier", `SELECT COUNT(*) FROM tier`, `SELECT COUNT(*) FROM tier`, failed) + verifyContent(sqlDB, pgDB, "tier", + `SELECT id, code, name FROM tier ORDER BY id`, + `SELECT id, code, name FROM tier ORDER BY id COLLATE "C"`, + failed) + verifyCount(sqlDB, pgDB, "user", `SELECT COUNT(*) FROM user`, `SELECT COUNT(*) FROM "user"`, failed) + verifyContent(sqlDB, pgDB, "user", + `SELECT id, user, role, sync_topic FROM user ORDER BY id`, + `SELECT id, user_name, role, sync_topic FROM "user" ORDER BY id COLLATE "C"`, + failed) + verifyCount(sqlDB, pgDB, "user_access", `SELECT COUNT(*) FROM user_access a JOIN user u ON u.id = a.user_id`, `SELECT COUNT(*) FROM user_access`, failed) + verifyContent(sqlDB, pgDB, "user_access", + `SELECT a.user_id, a.topic FROM user_access a JOIN user u ON u.id = a.user_id ORDER BY a.user_id, a.topic`, + `SELECT user_id, topic FROM user_access ORDER BY user_id COLLATE "C", topic COLLATE "C"`, + failed) + verifyCount(sqlDB, pgDB, "user_token", `SELECT COUNT(*) FROM user_token t JOIN user u ON u.id = t.user_id`, `SELECT COUNT(*) FROM user_token`, failed) + verifyContent(sqlDB, pgDB, "user_token", + `SELECT t.user_id, t.token, t.label FROM user_token t JOIN user u ON u.id = t.user_id ORDER BY t.user_id, t.token`, + `SELECT user_id, token, label FROM user_token ORDER BY user_id COLLATE "C", token COLLATE "C"`, + failed) + verifyCount(sqlDB, pgDB, "user_phone", `SELECT COUNT(*) FROM user_phone p JOIN user u ON u.id = p.user_id`, `SELECT COUNT(*) FROM user_phone`, failed) + verifyContent(sqlDB, pgDB, "user_phone", + `SELECT p.user_id, p.phone_number FROM user_phone p JOIN user u ON u.id = p.user_id ORDER BY p.user_id, p.phone_number`, + `SELECT user_id, phone_number FROM user_phone ORDER BY user_id COLLATE "C", phone_number COLLATE "C"`, + failed) + return nil } @@ -673,6 +698,7 @@ func verifyMessages(sqliteFile string, pgDB *sql.DB, failed *bool) error { defer sqlDB.Close() verifyCount(sqlDB, pgDB, "messages", `SELECT COUNT(*) FROM messages`, `SELECT COUNT(*) FROM message`, failed) + verifySampledMessages(sqlDB, pgDB, failed) return nil } @@ -684,26 +710,179 @@ func verifyWebPush(sqliteFile string, pgDB *sql.DB, failed *bool) error { defer sqlDB.Close() verifyCount(sqlDB, pgDB, "subscription", `SELECT COUNT(*) FROM subscription`, `SELECT COUNT(*) FROM webpush_subscription`, failed) + verifyContent(sqlDB, pgDB, "subscription", + `SELECT id, endpoint, key_auth, key_p256dh, user_id FROM subscription ORDER BY id`, + `SELECT id, endpoint, key_auth, key_p256dh, user_id FROM webpush_subscription ORDER BY id COLLATE "C"`, + failed) + verifyCount(sqlDB, pgDB, "subscription_topic", `SELECT COUNT(*) FROM subscription_topic`, `SELECT COUNT(*) FROM webpush_subscription_topic`, failed) + verifyContent(sqlDB, pgDB, "subscription_topic", + `SELECT subscription_id, topic FROM subscription_topic ORDER BY subscription_id, topic`, + `SELECT subscription_id, topic FROM webpush_subscription_topic ORDER BY subscription_id COLLATE "C", topic COLLATE "C"`, + failed) + return nil } func verifyCount(sqlDB, pgDB *sql.DB, table, sqliteQuery, pgQuery string, failed *bool) { var sqliteCount, pgCount int64 if err := sqlDB.QueryRow(sqliteQuery).Scan(&sqliteCount); err != nil { - fmt.Printf(" %-20s ERROR reading SQLite: %s\n", table, err) + fmt.Printf(" %-25s count ERROR reading SQLite: %s\n", table, err) *failed = true return } if err := pgDB.QueryRow(pgQuery).Scan(&pgCount); err != nil { - fmt.Printf(" %-20s ERROR reading PostgreSQL: %s\n", table, err) + fmt.Printf(" %-25s count ERROR reading PostgreSQL: %s\n", table, err) *failed = true return } if sqliteCount == pgCount { - fmt.Printf(" %-20s OK (%d rows)\n", table, pgCount) + fmt.Printf(" %-25s count OK (%d rows)\n", table, pgCount) } else { - fmt.Printf(" %-20s MISMATCH: SQLite=%d, PostgreSQL=%d\n", table, sqliteCount, pgCount) + fmt.Printf(" %-25s count MISMATCH: SQLite=%d, PostgreSQL=%d\n", table, sqliteCount, pgCount) *failed = true } } + +func verifyContent(sqlDB, pgDB *sql.DB, table, sqliteQuery, pgQuery string, failed *bool) { + sqliteRows, err := sqlDB.Query(sqliteQuery) + if err != nil { + fmt.Printf(" %-25s content ERROR reading SQLite: %s\n", table, err) + *failed = true + return + } + defer sqliteRows.Close() + + pgRows, err := pgDB.Query(pgQuery) + if err != nil { + fmt.Printf(" %-25s content ERROR reading PostgreSQL: %s\n", table, err) + *failed = true + return + } + defer pgRows.Close() + + cols, err := sqliteRows.Columns() + if err != nil { + fmt.Printf(" %-25s content ERROR reading columns: %s\n", table, err) + *failed = true + return + } + numCols := len(cols) + + rowNum := 0 + mismatches := 0 + for sqliteRows.Next() { + rowNum++ + if !pgRows.Next() { + fmt.Printf(" %-25s content MISMATCH: PostgreSQL has fewer rows (at row %d)\n", table, rowNum) + *failed = true + return + } + sqliteVals := makeStringSlice(numCols) + pgVals := makeStringSlice(numCols) + if err := sqliteRows.Scan(sqliteVals...); err != nil { + fmt.Printf(" %-25s content ERROR scanning SQLite row %d: %s\n", table, rowNum, err) + *failed = true + return + } + if err := pgRows.Scan(pgVals...); err != nil { + fmt.Printf(" %-25s content ERROR scanning PostgreSQL row %d: %s\n", table, rowNum, err) + *failed = true + return + } + for i := 0; i < numCols; i++ { + sv := *(sqliteVals[i].(*sql.NullString)) + pv := *(pgVals[i].(*sql.NullString)) + if sv != pv { + mismatches++ + if mismatches <= 3 { + fmt.Printf(" %-25s content MISMATCH at row %d, col %s: SQLite=%q, PostgreSQL=%q\n", table, rowNum, cols[i], sv.String, pv.String) + } + } + } + } + if pgRows.Next() { + fmt.Printf(" %-25s content MISMATCH: PostgreSQL has more rows than SQLite\n", table) + *failed = true + return + } + if mismatches > 0 { + if mismatches > 3 { + fmt.Printf(" %-25s content ... and %d more mismatches\n", table, mismatches-3) + } + *failed = true + } else { + fmt.Printf(" %-25s content OK\n", table) + } +} + +func verifySampledMessages(sqlDB, pgDB *sql.DB, failed *bool) { + rows, err := sqlDB.Query(`SELECT mid, topic, time, message, title, tags, priority FROM messages ORDER BY mid`) + if err != nil { + fmt.Printf(" %-25s content ERROR reading SQLite: %s\n", "messages (sampled)", err) + *failed = true + return + } + defer rows.Close() + + rowNum := 0 + checked := 0 + mismatches := 0 + for rows.Next() { + rowNum++ + var mid, topic, message, title, tags string + var msgTime int64 + var priority int + if err := rows.Scan(&mid, &topic, &msgTime, &message, &title, &tags, &priority); err != nil { + fmt.Printf(" %-25s content ERROR scanning SQLite row %d: %s\n", "messages (sampled)", rowNum, err) + *failed = true + return + } + if rowNum%100 != 1 { + continue + } + checked++ + var pgTopic, pgMessage, pgTitle, pgTags string + var pgTime int64 + var pgPriority int + err := pgDB.QueryRow(`SELECT topic, time, message, title, tags, priority FROM message WHERE mid = $1`, mid). + Scan(&pgTopic, &pgTime, &pgMessage, &pgTitle, &pgTags, &pgPriority) + if err == sql.ErrNoRows { + mismatches++ + if mismatches <= 3 { + fmt.Printf(" %-25s content MISMATCH: mid=%s not found in PostgreSQL\n", "messages (sampled)", mid) + } + continue + } else if err != nil { + fmt.Printf(" %-25s content ERROR querying PostgreSQL for mid=%s: %s\n", "messages (sampled)", mid, err) + *failed = true + return + } + topic = toUTF8(topic) + message = toUTF8(message) + title = toUTF8(title) + tags = toUTF8(tags) + if topic != pgTopic || msgTime != pgTime || message != pgMessage || title != pgTitle || tags != pgTags || priority != pgPriority { + mismatches++ + if mismatches <= 3 { + fmt.Printf(" %-25s content MISMATCH at mid=%s\n", "messages (sampled)", mid) + } + } + } + if mismatches > 0 { + if mismatches > 3 { + fmt.Printf(" %-25s content ... and %d more mismatches\n", "messages (sampled)", mismatches-3) + } + *failed = true + } else { + fmt.Printf(" %-25s content OK (%d samples checked)\n", "messages (sampled)", checked) + } +} + +func makeStringSlice(n int) []any { + vals := make([]any, n) + for i := range vals { + vals[i] = &sql.NullString{} + } + return vals +} From c66fa9234174a9cd79614225461dd5ac62baa79b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Feb 2026 16:54:56 -0500 Subject: [PATCH 235/548] Re-add tx integrity --- .gitignore | 1 + user/manager.go | 20 ++-------- user/store.go | 98 ++++++++++++++++++++++++++++++++++------------ user/store_test.go | 4 +- 4 files changed, 79 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index cf10bc33..3a362286 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ server/docs/ server/site/ tools/fbsend/fbsend +tools/pgimport/pgimport playground/ secrets/ *.iml diff --git a/user/manager.go b/user/manager.go index 2a962be2..34a6d33b 100644 --- a/user/manager.go +++ b/user/manager.go @@ -355,7 +355,7 @@ func (a *Manager) MarkUserRemoved(user *User) error { if !AllowedUsername(user.Name) { return ErrInvalidArgument } - return a.store.MarkUserRemoved(user.ID) + return a.store.MarkUserRemoved(user.ID, user.Name) } // Users returns a list of users. It always also returns the Everyone user ("*"). @@ -552,13 +552,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { return ErrInvalidArgument } - if err := a.store.AllowAccess(username, topic, true, true, username, false); err != nil { - return err - } - if err := a.store.AllowAccess(Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username, false); err != nil { - return err - } - return nil + return a.store.AddReservation(username, topic, everyone) } // RemoveReservations deletes the access control entries associated with the given username/topic, as @@ -572,15 +566,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { return ErrInvalidArgument } } - for _, topic := range topics { - if err := a.store.ResetAccess(username, topic); err != nil { - return err - } - if err := a.store.ResetAccess(Everyone, topic); err != nil { - return err - } - } - return nil + return a.store.RemoveReservations(username, topics...) } // DefaultAccess returns the default read/write access if no access control entry matches diff --git a/user/store.go b/user/store.go index fd9a3fbb..7ebb5b58 100644 --- a/user/store.go +++ b/user/store.go @@ -24,7 +24,7 @@ type Store interface { UsersCount() (int64, error) AddUser(username, hash string, role Role, provisioned bool) error RemoveUser(username string) error - MarkUserRemoved(userID string) error + MarkUserRemoved(userID, username string) error RemoveDeletedUsers() error ChangePassword(username, hash string) error ChangeRole(username string, role Role) error @@ -53,6 +53,8 @@ type Store interface { AllowAccess(username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error ResetAccess(username, topicPattern string) error ResetAllProvisionedAccess() error + AddReservation(username, topic string, everyone Permission) error + RemoveReservations(username string, topics ...string) error Reservations(username string) ([]Reservation, error) HasReservation(username, topic string) (bool, error) ReservationsCount(username string) (int64, error) @@ -141,6 +143,12 @@ type storeQueries struct { updateBilling string } +// execer is satisfied by both *sql.DB and *sql.Tx, allowing helper methods +// to be used both standalone and within a transaction. +type execer interface { + Exec(query string, args ...any) (sql.Result, error) +} + // commonStore implements store operations that work across database backends type commonStore struct { db *sql.DB @@ -259,22 +267,13 @@ func (s *commonStore) RemoveUser(username string) error { } // MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens -func (s *commonStore) MarkUserRemoved(userID string) error { +func (s *commonStore) MarkUserRemoved(userID, username string) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() - // Get username for deleteUserAccess query (must be inside tx for consistency) - rows, err := tx.Query(s.queries.selectUserByID, userID) - if err != nil { - return err - } - user, err := s.readUser(rows) - if err != nil { - return err - } - if _, err := tx.Exec(s.queries.deleteUserAccess, user.Name, user.Name); err != nil { + if _, err := tx.Exec(s.queries.deleteUserAccess, username, username); err != nil { return err } if _, err := tx.Exec(s.queries.deleteAllToken, userID); err != nil { @@ -413,12 +412,12 @@ func (s *commonStore) readUser(rows *sql.Rows) (*User, error) { Calls: calls, }, Billing: &Billing{ - StripeCustomerID: stripeCustomerID.String, - StripeSubscriptionID: stripeSubscriptionID.String, - StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), - StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), - StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), - StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), + StripeCustomerID: stripeCustomerID.String, // May be empty + StripeSubscriptionID: stripeSubscriptionID.String, // May be empty + StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty + StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty + StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero + StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero }, Deleted: deleted.Valid, } @@ -426,6 +425,7 @@ func (s *commonStore) readUser(rows *sql.Rows) (*User, error) { return nil, err } if tierCode.Valid { + // See readTier() when this is changed! user.Tier = &Tier{ ID: tierID.String, Code: tierCode.String, @@ -439,8 +439,8 @@ func (s *commonStore) readUser(rows *sql.Rows) (*User, error) { AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, - StripeMonthlyPriceID: stripeMonthlyPriceID.String, - StripeYearlyPriceID: stripeYearlyPriceID.String, + StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty + StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty } } return user, nil @@ -612,6 +612,10 @@ func (s *commonStore) readToken(rows *sql.Rows) (*Token, error) { // AuthorizeTopicAccess returns the read/write permissions for the given username and topic. // The found return value indicates whether an ACL entry was found at all. +// +// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user. +// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" +// - It also prioritizes write permissions over read permissions func (s *commonStore) AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) { rows, err := s.db.Query(s.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic) if err != nil { @@ -689,10 +693,12 @@ func (s *commonStore) AllowAccess(username, topicPattern string, read, write boo } else if !AllowedTopicPattern(topicPattern) { return ErrInvalidArgument } - if _, err := s.db.Exec(s.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned); err != nil { - return err - } - return nil + return s.allowAccessTx(s.db, username, topicPattern, read, write, ownerUsername, provisioned) +} + +func (s *commonStore) allowAccessTx(tx execer, username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { + _, err := tx.Exec(s.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned) + return err } // ResetAccess removes an access control list entry @@ -709,7 +715,11 @@ func (s *commonStore) ResetAccess(username, topicPattern string) error { _, err := s.db.Exec(s.queries.deleteUserAccess, username, username) return err } - _, err := s.db.Exec(s.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern)) + return s.resetTopicAccessTx(s.db, username, topicPattern) +} + +func (s *commonStore) resetTopicAccessTx(tx execer, username, topicPattern string) error { + _, err := tx.Exec(s.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern)) return err } @@ -721,6 +731,44 @@ func (s *commonStore) ResetAllProvisionedAccess() error { return nil } +// AddReservation creates two access control entries for the given topic: one with full read/write +// access for the given user, and one for Everyone with the given permission. Both entries are +// created atomically in a single transaction. +func (s *commonStore) AddReservation(username, topic string, everyone Permission) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := s.allowAccessTx(tx, username, topic, true, true, username, false); err != nil { + return err + } + if err := s.allowAccessTx(tx, Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username, false); err != nil { + return err + } + return tx.Commit() +} + +// RemoveReservations deletes the access control entries associated with the given username/topic, +// as well as all entries with Everyone/topic. All deletions are performed atomically in a single +// transaction. +func (s *commonStore) RemoveReservations(username string, topics ...string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, topic := range topics { + if err := s.resetTopicAccessTx(tx, username, topic); err != nil { + return err + } + if err := s.resetTopicAccessTx(tx, Everyone, topic); err != nil { + return err + } + } + return tx.Commit() +} + // Reservations returns all user-owned topics, and the associated everyone-access func (s *commonStore) Reservations(username string) ([]Reservation, error) { rows, err := s.db.Query(s.queries.selectUserReservations, Everyone, username) diff --git a/user/store_test.go b/user/store_test.go index c5c40a45..6ed88722 100644 --- a/user/store_test.go +++ b/user/store_test.go @@ -656,7 +656,7 @@ func TestStoreMarkUserRemoved(t *testing.T) { u, err := store.User("phil") require.Nil(t, err) - require.Nil(t, store.MarkUserRemoved(u.ID)) + require.Nil(t, store.MarkUserRemoved(u.ID, u.Name)) u2, err := store.User("phil") require.Nil(t, err) @@ -670,7 +670,7 @@ func TestStoreRemoveDeletedUsers(t *testing.T) { u, err := store.User("phil") require.Nil(t, err) - require.Nil(t, store.MarkUserRemoved(u.ID)) + require.Nil(t, store.MarkUserRemoved(u.ID, u.Name)) // RemoveDeletedUsers only removes users past the hard-delete duration (7 days). // Immediately after marking, the user should still exist. From 5c26e70fe7a92b8f1d2a08ed77727b0539fe6edf Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Feb 2026 17:34:19 -0500 Subject: [PATCH 236/548] Manual review: Make resetUserAccessTx --- user/store.go | 54 +++++++++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/user/store.go b/user/store.go index 7ebb5b58..6f5fb606 100644 --- a/user/store.go +++ b/user/store.go @@ -155,18 +155,18 @@ type commonStore struct { queries storeQueries } -// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise -func (s *commonStore) UserByID(id string) (*User, error) { - rows, err := s.db.Query(s.queries.selectUserByID, id) +// User returns the user with the given username if it exists, or ErrUserNotFound otherwise +func (s *commonStore) User(username string) (*User, error) { + rows, err := s.db.Query(s.queries.selectUserByName, username) if err != nil { return nil, err } return s.readUser(rows) } -// User returns the user with the given username if it exists, or ErrUserNotFound otherwise -func (s *commonStore) User(username string) (*User, error) { - rows, err := s.db.Query(s.queries.selectUserByName, username) +// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise +func (s *commonStore) UserByID(id string) (*User, error) { + rows, err := s.db.Query(s.queries.selectUserByID, id) if err != nil { return nil, err } @@ -273,7 +273,7 @@ func (s *commonStore) MarkUserRemoved(userID, username string) error { return err } defer tx.Rollback() - if _, err := tx.Exec(s.queries.deleteUserAccess, username, username); err != nil { + if err := s.resetUserAccessTx(tx, username); err != nil { return err } if _, err := tx.Exec(s.queries.deleteAllToken, userID); err != nil { @@ -317,7 +317,7 @@ func (s *commonStore) ChangeRole(username string, role Role) error { } // If changing to admin, remove all access entries if role == RoleAdmin { - if _, err := tx.Exec(s.queries.deleteUserAccess, username, username); err != nil { + if err := s.resetUserAccessTx(tx, username); err != nil { return err } } @@ -383,6 +383,7 @@ func (s *commonStore) ResetStats() error { } return nil } + func (s *commonStore) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var id, username, hash, role, prefs, syncTopic string @@ -688,37 +689,44 @@ func (s *commonStore) Grants(username string) ([]Grant, error) { // AllowAccess adds or updates an entry in the access control list func (s *commonStore) AllowAccess(username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { + return s.allowAccessTx(s.db, username, topicPattern, read, write, ownerUsername, provisioned) +} + +func (s *commonStore) allowAccessTx(tx execer, username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { if !AllowedUsername(username) && username != Everyone { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) { return ErrInvalidArgument } - return s.allowAccessTx(s.db, username, topicPattern, read, write, ownerUsername, provisioned) -} - -func (s *commonStore) allowAccessTx(tx execer, username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { _, err := tx.Exec(s.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned) return err } // ResetAccess removes an access control list entry func (s *commonStore) ResetAccess(username, topicPattern string) error { + if username == "" && topicPattern == "" { + _, err := s.db.Exec(s.queries.deleteAllAccess) + return err + } else if topicPattern == "" { + return s.resetUserAccessTx(s.db, username) + } + return s.resetTopicAccessTx(s.db, username, topicPattern) +} + +func (s *commonStore) resetUserAccessTx(tx execer, username string) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } + _, err := tx.Exec(s.queries.deleteUserAccess, username, username) + return err +} + +func (s *commonStore) resetTopicAccessTx(tx execer, username, topicPattern string) error { if !AllowedUsername(username) && username != Everyone && username != "" { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { return ErrInvalidArgument } - if username == "" && topicPattern == "" { - _, err := s.db.Exec(s.queries.deleteAllAccess) - return err - } else if topicPattern == "" { - _, err := s.db.Exec(s.queries.deleteUserAccess, username, username) - return err - } - return s.resetTopicAccessTx(s.db, username, topicPattern) -} - -func (s *commonStore) resetTopicAccessTx(tx execer, username, topicPattern string) error { _, err := tx.Exec(s.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern)) return err } From da1c7b19496161185a9d7cac143e43289098a58e Mon Sep 17 00:00:00 2001 From: ezn24 Date: Thu, 26 Feb 2026 08:23:11 +0100 Subject: [PATCH 237/548] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/ --- web/public/static/langs/zh_Hant.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index 7f2cbdad..a340e2c7 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -361,7 +361,7 @@ "publish_dialog_tags_placeholder": "英文逗號分隔標記列表,例如 warning, srv1-backup", "publish_dialog_title_label": "主題", "publish_dialog_title_no_topic": "發布通知", - "publish_dialog_title_placeholder": "主題標題,例如 磁碟空間警告", + "publish_dialog_title_placeholder": "主題標題,例如:磁碟空間警告", "publish_dialog_title_topic": "發布到 {{topic}}", "publish_dialog_topic_label": "主題名稱", "publish_dialog_topic_placeholder": "主題名稱,例如 phil_alerts", From a1cca7972d5cfc3b0ff2a19f1c61918671eb8010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bora=20At=C4=B1c=C4=B1?= Date: Thu, 26 Feb 2026 23:46:07 +0100 Subject: [PATCH 238/548] Translated using Weblate (Turkish) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/ --- web/public/static/langs/tr.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/tr.json b/web/public/static/langs/tr.json index 91746b28..864bb7b8 100644 --- a/web/public/static/langs/tr.json +++ b/web/public/static/langs/tr.json @@ -403,5 +403,7 @@ "web_push_subscription_expiring_body": "Bildirimleri almaya devam etmek için ntfy'yi açın", "web_push_unknown_notification_title": "Sunucudan bilinmeyen bildirim alındı", "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" + "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" } From c49a8179cf4ef9fd0bcd4ad135210712eae0fcfa Mon Sep 17 00:00:00 2001 From: azrikahar <42867097+azrikahar@users.noreply.github.com> Date: Sat, 28 Feb 2026 14:55:25 +0800 Subject: [PATCH 239/548] align backup-service user references in token via the config section --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 8a125146..822e5ee3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -454,7 +454,7 @@ Here's an example: ``` # Comma-separated list NTFY_AUTH_FILE='/var/lib/ntfy/user.db' - NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' + NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-service:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76,backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script' ``` From e9cf2b55231fee9761f0a25011b2148f9c858b53 Mon Sep 17 00:00:00 2001 From: azrikahar <42867097+azrikahar@users.noreply.github.com> Date: Sat, 28 Feb 2026 14:56:40 +0800 Subject: [PATCH 240/548] align backup-script user references in private instance section --- docs/config.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 822e5ee3..23fb5029 100644 --- a/docs/config.md +++ b/docs/config.md @@ -481,7 +481,7 @@ that all other users and anonymous access are denied by default. - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin" - "backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user" auth-access: - - "backup-service:backups:rw" + - "backup-script:backups:rw" auth-tokens: - "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token" ``` @@ -491,7 +491,7 @@ that all other users and anonymous access are denied by default. NTFY_AUTH_FILE='/var/lib/ntfy/user.db' NTFY_AUTH_DEFAULT_ACCESS='deny-all' NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user' - NTFY_AUTH_ACCESS='backup-service:backups:rw' + NTFY_AUTH_ACCESS='backup-script:backups:rw' NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token' ``` From e618cf1a397cb95c4f099e9a7f1fb5ece4ded466 Mon Sep 17 00:00:00 2001 From: azrikahar <42867097+azrikahar@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:03:09 +0800 Subject: [PATCH 241/548] mention token in private instance example --- docs/config.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/config.md b/docs/config.md index 23fb5029..39e3e689 100644 --- a/docs/config.md +++ b/docs/config.md @@ -470,7 +470,8 @@ and access tokens in the `auth-tokens` section (see [access tokens via the confi Here's an example that defines a single admin user `phil` with the password `mypass`, and a regular user `backup-script` with the password `backup-script`. The admin user has full access to all topics, while regular user can only -access the `backups` topic with read-write permissions. The `auth-default-access` is set to `deny-all`, which means +access the `backups` topic with read-write permissions. `phil` has a token `tk_3gd7d2yftt4b8ixyfe9mnmro88o76` +with the label "My personal token". The `auth-default-access` is set to `deny-all`, which means that all other users and anonymous access are denied by default. === "Config via /etc/ntfy/server.yml" @@ -1746,7 +1747,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). not support dashes. | Config option | Env variable | Format | Default | Description | -|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ------------------------------------------ | ----------------------------------------------- | --------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | | `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | | `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | @@ -1803,7 +1804,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | | `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | | `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | -| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login | +| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login | | `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | | `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | | `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | From b937b44f2d85ad758a32c178379f5ad2bbbdc9b4 Mon Sep 17 00:00:00 2001 From: azrikahar <42867097+azrikahar@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:06:37 +0800 Subject: [PATCH 242/548] undo unrelated formatting changes --- docs/config.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 39e3e689..967b3776 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1747,7 +1747,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). not support dashes. | Config option | Env variable | Format | Default | Description | -| ------------------------------------------ | ----------------------------------------------- | --------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | | `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | | `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | @@ -1804,7 +1804,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | | `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | | `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | -| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login | +| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login | | `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | | `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | | `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | From 7710ace1844ef8add2115057583528d87dc7a7d6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 28 Feb 2026 16:13:58 -0500 Subject: [PATCH 243/548] Self-review --- user/store.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/user/store.go b/user/store.go index 6f5fb606..4460f181 100644 --- a/user/store.go +++ b/user/store.go @@ -795,7 +795,7 @@ func (s *commonStore) Reservations(username string) ([]Reservation, error) { return nil, err } reservations = append(reservations, Reservation{ - Topic: unescapeUnderscore(topic), + Topic: fromSQLWildcard(topic), Owner: NewPermission(ownerRead, ownerWrite), Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool), }) @@ -895,6 +895,7 @@ func (s *commonStore) RemoveTier(code string) error { if !AllowedTier(code) { return ErrInvalidArgument } + // This fails if any user has this tier if _, err := s.db.Exec(s.queries.deleteTier, code); err != nil { return err } @@ -940,6 +941,7 @@ func (s *commonStore) TierByStripePrice(priceID string) (*Tier, error) { defer rows.Close() return s.readTier(rows) } + func (s *commonStore) readTier(rows *sql.Rows) (*Tier, error) { var id, code, name string var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString @@ -952,6 +954,7 @@ func (s *commonStore) readTier(rows *sql.Rows) (*Tier, error) { } else if err := rows.Err(); err != nil { return nil, err } + // When changed, note readUser() as well return &Tier{ ID: id, Code: code, @@ -965,8 +968,8 @@ func (s *commonStore) readTier(rows *sql.Rows) (*Tier, error) { AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, - StripeMonthlyPriceID: stripeMonthlyPriceID.String, - StripeYearlyPriceID: stripeYearlyPriceID.String, + StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty + StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty }, nil } From ebb48e217d644040c1751db0019ae4a8e8fe861f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 28 Feb 2026 17:35:35 -0500 Subject: [PATCH 244/548] Merge user store and manager --- cmd/user.go | 12 +- server/server.go | 9 +- user/manager.go | 1147 ++++++++++++++--- ...{store_postgres.go => manager_postgres.go} | 15 +- ...s_schema.go => manager_postgres_schema.go} | 0 user/{store_sqlite.go => manager_sqlite.go} | 15 +- ...ite_schema.go => manager_sqlite_schema.go} | 0 user/manager_test.go | 859 ++++++++++-- user/store.go | 1059 --------------- user/store_test.go | 713 ---------- user/types.go | 81 +- 11 files changed, 1816 insertions(+), 2094 deletions(-) rename user/{store_postgres.go => manager_postgres.go} (97%) rename user/{store_postgres_schema.go => manager_postgres_schema.go} (100%) rename user/{store_sqlite.go => manager_sqlite.go} (98%) rename user/{store_sqlite_schema.go => manager_sqlite_schema.go} (100%) delete mode 100644 user/store.go delete mode 100644 user/store_test.go diff --git a/cmd/user.go b/cmd/user.go index 808920be..e0041151 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -378,25 +378,19 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, } - var store user.Store if databaseURL != "" { pool, dbErr := db.OpenPostgres(databaseURL) if dbErr != nil { return nil, dbErr } - store, err = user.NewPostgresStore(pool) + return user.NewPostgresManager(pool, authConfig) } else if authFile != "" { if !util.FileExists(authFile) { return nil, errors.New("auth-file does not exist; please start the server at least once to create it") } - store, err = user.NewSQLiteStore(authFile, authStartupQueries) - } else { - return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server") + return user.NewSQLiteManager(authFile, authStartupQueries, authConfig) } - if err != nil { - return nil, err - } - return user.NewManager(store, authConfig) + return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server") } func readPasswordAndConfirm(c *cli.Context) (string, error) { diff --git a/server/server.go b/server/server.go index 1b6c8d10..55caaa53 100644 --- a/server/server.go +++ b/server/server.go @@ -235,19 +235,14 @@ func New(conf *Config) (*Server, error) { BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } - var store user.Store if pool != nil { - store, err = user.NewPostgresStore(pool) + userManager, err = user.NewPostgresManager(pool, authConfig) } else { - store, err = user.NewSQLiteStore(conf.AuthFile, conf.AuthStartupQueries) + userManager, err = user.NewSQLiteManager(conf.AuthFile, conf.AuthStartupQueries, authConfig) } if err != nil { return nil, err } - userManager, err = user.NewManager(store, authConfig) - if err != nil { - return nil, err - } } var firebaseClient *firebaseClient if conf.FirebaseKeyFile != "" { diff --git a/user/manager.go b/user/manager.go index 34a6d33b..d18bbc8f 100644 --- a/user/manager.go +++ b/user/manager.go @@ -2,15 +2,19 @@ package user import ( + "database/sql" + "encoding/json" "errors" "fmt" "net/netip" "slices" + "strings" "sync" "time" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/util" ) @@ -44,7 +48,8 @@ var ( // Manager handles user authentication, authorization, and management type Manager struct { config *Config - store Store // Database store + db *sql.DB + queries storeQueries statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) mu sync.Mutex @@ -52,26 +57,21 @@ type Manager struct { var _ Auther = (*Manager)(nil) -// NewManager creates a new Manager instance -func NewManager(store Store, config *Config) (*Manager, error) { - // Set defaults - if config.BcryptCost <= 0 { - config.BcryptCost = DefaultUserPasswordBcryptCost +// initManager sets defaults and runs startup tasks common to all backends +func initManager(manager *Manager) error { + if manager.config.BcryptCost <= 0 { + manager.config.BcryptCost = DefaultUserPasswordBcryptCost } - if config.QueueWriterInterval.Seconds() <= 0 { - config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval - } - manager := &Manager{ - store: store, - config: config, - statsQueue: make(map[string]*Stats), - tokenQueue: make(map[string]*TokenUpdate), + if manager.config.QueueWriterInterval.Seconds() <= 0 { + manager.config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } + manager.statsQueue = make(map[string]*Stats) + manager.tokenQueue = make(map[string]*TokenUpdate) if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil { - return nil, err + return err } - go manager.asyncQueueWriter(config.QueueWriterInterval) - return manager, nil + go manager.asyncQueueWriter(manager.config.QueueWriterInterval) + return nil } // Authenticate checks username and password and returns a User if correct, and the user has not been @@ -81,7 +81,7 @@ func (a *Manager) Authenticate(username, password string) (*User, error) { if username == Everyone { return nil, ErrUnauthenticated } - user, err := a.store.User(username) + user, err := a.User(username) if err != nil { log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)") bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) @@ -103,7 +103,7 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { if len(token) != tokenLength { return nil, ErrUnauthenticated } - user, err := a.store.UserByToken(token) + user, err := a.UserByToken(token) if err != nil { log.Tag(tag).Field("token", token).Err(err).Trace("Authentication of token failed") return nil, ErrUnauthenticated @@ -116,17 +116,7 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { // after a fixed duration unless ChangeToken is called. This function also prunes tokens for the // given user, if there are too many of them. func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) { - return a.store.CreateToken(userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned) -} - -// Tokens returns all existing tokens for the user with the given user ID -func (a *Manager) Tokens(userID string) ([]*Token, error) { - return a.store.Tokens(userID) -} - -// Token returns a specific token for a user -func (a *Manager) Token(userID, token string) (*Token, error) { - return a.store.Token(userID, token) + return a.createToken(userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned) } // ChangeToken updates a token's label and/or expiry date @@ -137,7 +127,7 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time if err := a.canChangeToken(userID, token); err != nil { return nil, err } - t, err := a.store.Token(userID, token) + t, err := a.Token(userID, token) if err != nil { return nil, err } @@ -147,7 +137,7 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time if expires != nil { t.Expires = *expires } - if err := a.store.ChangeToken(userID, token, t.Label, t.Expires); err != nil { + if err := a.changeToken(userID, token, t.Label, t.Expires); err != nil { return nil, err } return t, nil @@ -158,7 +148,7 @@ func (a *Manager) RemoveToken(userID, token string) error { if err := a.canChangeToken(userID, token); err != nil { return err } - return a.store.RemoveToken(userID, token) + return a.removeToken(userID, token) } // canChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed. @@ -172,47 +162,25 @@ func (a *Manager) canChangeToken(userID, token string) error { return nil } -// RemoveExpiredTokens deletes all expired tokens from the database -func (a *Manager) RemoveExpiredTokens() error { - return a.store.RemoveExpiredTokens() -} - -// PhoneNumbers returns all phone numbers for the user with the given user ID -func (a *Manager) PhoneNumbers(userID string) ([]string, error) { - return a.store.PhoneNumbers(userID) -} - -// AddPhoneNumber adds a phone number to the user with the given user ID -func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { - return a.store.AddPhoneNumber(userID, phoneNumber) -} - -// RemovePhoneNumber deletes a phone number from the user with the given user ID -func (a *Manager) RemovePhoneNumber(userID string, phoneNumber string) error { - return a.store.RemovePhoneNumber(userID, phoneNumber) -} - -// RemoveDeletedUsers deletes all users that have been marked deleted -func (a *Manager) RemoveDeletedUsers() error { - return a.store.RemoveDeletedUsers() -} - -// ChangeSettings persists the user settings -func (a *Manager) ChangeSettings(userID string, prefs *Prefs) error { - return a.store.ChangeSettings(userID, prefs) -} - // ResetStats resets all user stats in the user database. This touches all users. func (a *Manager) ResetStats() error { a.mu.Lock() // Includes database query to avoid races! defer a.mu.Unlock() - if err := a.store.ResetStats(); err != nil { + if err := a.resetStats(); err != nil { return err } a.statsQueue = make(map[string]*Stats) return nil } +// resetStats resets all user stats in the user database +func (a *Manager) resetStats() error { + if _, err := a.db.Exec(a.queries.updateUserStatsResetAll); err != nil { + return err + } + return nil +} + // EnqueueUserStats adds the user to a queue which writes out user stats (messages, emails, ..) in // batches at a regular interval func (a *Manager) EnqueueUserStats(userID string, stats *Stats) { @@ -264,7 +232,7 @@ func (a *Manager) writeUserStatsQueue() error { }). Trace("Updating stats for user %s", userID) } - return a.store.UpdateStats(statsQueue) + return a.UpdateStats(statsQueue) } func (a *Manager) writeTokenUpdateQueue() error { @@ -282,7 +250,7 @@ func (a *Manager) writeTokenUpdateQueue() error { for tokenID, update := range tokenQueue { log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) } - return a.store.UpdateTokenLastAccess(tokenQueue) + return a.UpdateTokenLastAccess(tokenQueue) } // Authorize returns nil if the given user has access to the given topic using the desired @@ -296,7 +264,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { username = user.Name } // Select the read/write permissions for this user/topic combo. - read, write, found, err := a.store.AuthorizeTopicAccess(username, topic) + read, write, found, err := a.AuthorizeTopicAccess(username, topic) if err != nil { return err } @@ -337,7 +305,7 @@ func (a *Manager) addUser(username, password string, role Role, hashed, provisio return err } } - return a.store.AddUser(username, hash, role, provisioned) + return a.insertUser(username, hash, role, provisioned) } // RemoveUser deletes the user with the given username. The function returns nil on success, even @@ -346,7 +314,7 @@ func (a *Manager) RemoveUser(username string) error { if err := a.CanChangeUser(username); err != nil { return err } - return a.store.RemoveUser(username) + return a.removeUser(username) } // MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens. This prevents @@ -355,64 +323,7 @@ func (a *Manager) MarkUserRemoved(user *User) error { if !AllowedUsername(user.Name) { return ErrInvalidArgument } - return a.store.MarkUserRemoved(user.ID, user.Name) -} - -// Users returns a list of users. It always also returns the Everyone user ("*"). -func (a *Manager) Users() ([]*User, error) { - return a.store.Users() -} - -// UsersCount returns the number of users in the database -func (a *Manager) UsersCount() (int64, error) { - return a.store.UsersCount() -} - -// User returns the user with the given username if it exists, or ErrUserNotFound otherwise. -// You may also pass Everyone to retrieve the anonymous user and its Grant list. -func (a *Manager) User(username string) (*User, error) { - return a.store.User(username) -} - -// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise -func (a *Manager) UserByID(id string) (*User, error) { - return a.store.UserByID(id) -} - -// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise. -func (a *Manager) UserByStripeCustomer(stripeCustomerID string) (*User, error) { - return a.store.UserByStripeCustomer(stripeCustomerID) -} - -// AllGrants returns all user-specific access control entries, mapped to their respective user IDs -func (a *Manager) AllGrants() (map[string][]Grant, error) { - return a.store.AllGrants() -} - -// Grants returns all user-specific access control entries -func (a *Manager) Grants(username string) ([]Grant, error) { - return a.store.Grants(username) -} - -// Reservations returns all user-owned topics, and the associated everyone-access -func (a *Manager) Reservations(username string) ([]Reservation, error) { - return a.store.Reservations(username) -} - -// HasReservation returns true if the given topic access is owned by the user -func (a *Manager) HasReservation(username, topic string) (bool, error) { - return a.store.HasReservation(username, topic) -} - -// ReservationsCount returns the number of reservations owned by this user -func (a *Manager) ReservationsCount(username string) (int64, error) { - return a.store.ReservationsCount(username) -} - -// ReservationOwner returns user ID of the user that owns this topic, or an -// empty string if it's not owned by anyone -func (a *Manager) ReservationOwner(topic string) (string, error) { - return a.store.ReservationOwner(topic) + return a.markUserRemoved(user.ID, user.Name) } // ChangePassword changes a user's password @@ -433,7 +344,7 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { return err } } - return a.store.ChangePassword(username, hash) + return a.changePasswordHash(username, hash) } // CanChangeUser checks if the user with the given username can be changed. @@ -454,7 +365,7 @@ func (a *Manager) ChangeRole(username string, role Role) error { if err := a.CanChangeUser(username); err != nil { return err } - return a.store.ChangeRole(username, role) + return a.changeRole(username, role) } // ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, @@ -469,7 +380,7 @@ func (a *Manager) ChangeTier(username, tier string) error { } else if err := a.checkReservationsLimit(username, t.ReservationLimit); err != nil { return err } - return a.store.ChangeTier(username, tier) + return a.changeTierCode(username, tier) } // ResetTier removes the tier from the given user @@ -479,7 +390,7 @@ func (a *Manager) ResetTier(username string) error { } else if err := a.checkReservationsLimit(username, 0); err != nil { return err } - return a.store.ResetTier(username) + return a.resetTierCode(username) } func (a *Manager) checkReservationsLimit(username string, reservationsLimit int64) error { @@ -504,7 +415,7 @@ func (a *Manager) AllowReservation(username string, topic string) error { if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { return ErrInvalidArgument } - otherCount, err := a.store.OtherAccessCount(username, topic) + otherCount, err := a.OtherAccessCount(username, topic) if err != nil { return err } @@ -527,7 +438,7 @@ func (a *Manager) allowAccess(username string, topicPattern string, permission P } else if !AllowedTopicPattern(topicPattern) { return ErrInvalidArgument } - return a.store.AllowAccess(username, topicPattern, permission.IsRead(), permission.IsWrite(), "", provisioned) + return a.allowAccessTx(a.db, username, topicPattern, permission.IsRead(), permission.IsWrite(), "", provisioned) } // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is @@ -542,31 +453,13 @@ func (a *Manager) resetAccess(username string, topicPattern string) error { } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { return ErrInvalidArgument } - return a.store.ResetAccess(username, topicPattern) -} - -// AddReservation creates two access control entries for the given topic: one with full read/write access for the -// given user, and one for Everyone with the permission passed as everyone. The user also owns the entries, and -// can modify or delete them. -func (a *Manager) AddReservation(username string, topic string, everyone Permission) error { - if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { - return ErrInvalidArgument + if username == "" && topicPattern == "" { + _, err := a.db.Exec(a.queries.deleteAllAccess) + return err + } else if topicPattern == "" { + return a.resetUserAccessTx(a.db, username) } - return a.store.AddReservation(username, topic, everyone) -} - -// RemoveReservations deletes the access control entries associated with the given username/topic, as -// well as all entries with Everyone/topic. This is the counterpart for AddReservation. -func (a *Manager) RemoveReservations(username string, topics ...string) error { - if !AllowedUsername(username) || username == Everyone || len(topics) == 0 { - return ErrInvalidArgument - } - for _, topic := range topics { - if !AllowedTopic(topic) { - return ErrInvalidArgument - } - } - return a.store.RemoveReservations(username, topics...) + return a.resetTopicAccessTx(a.db, username, topicPattern) } // DefaultAccess returns the default read/write access if no access control entry matches @@ -574,44 +467,9 @@ func (a *Manager) DefaultAccess() Permission { return a.config.DefaultAccess } -// AddTier creates a new tier in the database -func (a *Manager) AddTier(tier *Tier) error { - return a.store.AddTier(tier) -} - -// UpdateTier updates a tier's properties in the database -func (a *Manager) UpdateTier(tier *Tier) error { - return a.store.UpdateTier(tier) -} - -// RemoveTier deletes the tier with the given code -func (a *Manager) RemoveTier(code string) error { - return a.store.RemoveTier(code) -} - -// ChangeBilling updates a user's billing fields, namely the Stripe customer ID, and subscription information -func (a *Manager) ChangeBilling(username string, billing *Billing) error { - return a.store.ChangeBilling(username, billing) -} - -// Tiers returns a list of all Tier structs -func (a *Manager) Tiers() ([]*Tier, error) { - return a.store.Tiers() -} - -// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist -func (a *Manager) Tier(code string) (*Tier, error) { - return a.store.Tier(code) -} - -// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist -func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { - return a.store.TierByStripePrice(priceID) -} - // Close closes the underlying database func (a *Manager) Close() error { - return a.store.Close() + return a.db.Close() } // maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config. @@ -646,7 +504,7 @@ func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers if user.Name == Everyone { continue } else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) { - if err := a.store.RemoveUser(user.Name); err != nil { + if err := a.removeUser(user.Name); err != nil { return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err) } } @@ -665,17 +523,17 @@ func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers } } else { if !existingUser.Provisioned { - if err := a.store.ChangeProvisioned(user.Name, true); err != nil { + if err := a.ChangeProvisioned(user.Name, true); err != nil { return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err) } } if existingUser.Hash != user.Hash { - if err := a.store.ChangePassword(user.Name, user.Hash); err != nil { + if err := a.changePasswordHash(user.Name, user.Hash); err != nil { return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) } } if existingUser.Role != user.Role { - if err := a.store.ChangeRole(user.Name, user.Role); err != nil { + if err := a.changeRole(user.Name, user.Role); err != nil { return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) } } @@ -690,7 +548,7 @@ func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers // access time) or do not have dependent resources (such as grants or tokens). func (a *Manager) maybeProvisionGrants() error { // Remove all provisioned grants - if err := a.store.ResetAllProvisionedAccess(); err != nil { + if err := a.ResetAllProvisionedAccess(); err != nil { return err } // (Re-)add provisioned grants @@ -717,7 +575,7 @@ func (a *Manager) maybeProvisionGrants() error { func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { // Remove tokens that are provisioned, but not in the config anymore - existingTokens, err := a.store.AllProvisionedTokens() + existingTokens, err := a.AllProvisionedTokens() if err != nil { return fmt.Errorf("failed to retrieve existing provisioned tokens: %v", err) } @@ -729,7 +587,7 @@ func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { } for _, existingToken := range existingTokens { if !slices.Contains(provisionTokens, existingToken.Value) { - if err := a.store.RemoveProvisionedToken(existingToken.Value); err != nil { + if err := a.RemoveProvisionedToken(existingToken.Value); err != nil { return fmt.Errorf("failed to remove provisioned token %s: %v", existingToken.Value, err) } } @@ -739,15 +597,900 @@ func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { if !slices.Contains(provisionUsernames, username) && username != Everyone { return fmt.Errorf("user %s is not a provisioned user, refusing to add tokens", username) } - userID, err := a.store.UserIDByUsername(username) + userID, err := a.UserIDByUsername(username) if err != nil { return fmt.Errorf("failed to find provisioned user %s for provisioned tokens: %v", username, err) } for _, token := range tokens { - if _, err := a.store.CreateToken(userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), 0, true); err != nil { + if _, err := a.createToken(userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), 0, true); err != nil { return err } } } return nil } + +// User returns the user with the given username if it exists, or ErrUserNotFound otherwise +func (a *Manager) User(username string) (*User, error) { + rows, err := a.db.Query(a.queries.selectUserByName, username) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise +func (a *Manager) UserByID(id string) (*User, error) { + rows, err := a.db.Query(a.queries.selectUserByID, id) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +// UserByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise +func (a *Manager) UserByToken(token string) (*User, error) { + rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix()) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise +func (a *Manager) UserByStripeCustomer(customerID string) (*User, error) { + rows, err := a.db.Query(a.queries.selectUserByStripeCustomerID, customerID) + if err != nil { + return nil, err + } + return a.readUser(rows) +} + +// Users returns a list of users +func (a *Manager) Users() ([]*User, error) { + rows, err := a.db.Query(a.queries.selectUsernames) + if err != nil { + return nil, err + } + defer rows.Close() + usernames := make([]string, 0) + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + usernames = append(usernames, username) + } + rows.Close() + users := make([]*User, 0) + for _, username := range usernames { + user, err := a.User(username) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, nil +} + +// UsersCount returns the number of users in the database +func (a *Manager) UsersCount() (int64, error) { + rows, err := a.db.Query(a.queries.selectUserCount) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// insertUser adds a user with the given username, password hash and role to the database +func (a *Manager) insertUser(username, hash string, role Role, provisioned bool) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + userID := util.RandomStringPrefix(userIDPrefix, userIDLength) + syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) + now := time.Now().Unix() + if _, err := a.db.Exec(a.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil { + if isUniqueConstraintError(err) { + return ErrUserExists + } + return err + } + return nil +} + +// removeUser deletes the user with the given username +func (a *Manager) removeUser(username string) error { + if !AllowedUsername(username) { + return ErrInvalidArgument + } + // Rows in user_access, user_token, etc. are deleted via foreign keys + if _, err := a.db.Exec(a.queries.deleteUser, username); err != nil { + return err + } + return nil +} + +// markUserRemoved sets the deleted flag on the user, and deletes all access tokens +func (a *Manager) markUserRemoved(userID, username string) error { + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := a.resetUserAccessTx(tx, username); err != nil { + return err + } + if _, err := tx.Exec(a.queries.deleteAllToken, userID); err != nil { + return err + } + deletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix() + if _, err := tx.Exec(a.queries.updateUserDeleted, deletedTime, userID); err != nil { + return err + } + return tx.Commit() +} + +// RemoveDeletedUsers deletes all users that have been marked deleted +func (a *Manager) RemoveDeletedUsers() error { + if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil { + return err + } + return nil +} + +// changePasswordHash changes a user's password hash in the database +func (a *Manager) changePasswordHash(username, hash string) error { + if _, err := a.db.Exec(a.queries.updateUserPass, hash, username); err != nil { + return err + } + return nil +} + +// changeRole changes a user's role +func (a *Manager) changeRole(username string, role Role) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(a.queries.updateUserRole, string(role), username); err != nil { + return err + } + // If changing to admin, remove all access entries + if role == RoleAdmin { + if err := a.resetUserAccessTx(tx, username); err != nil { + return err + } + } + return tx.Commit() +} + +// ChangeProvisioned changes the provisioned status of a user +func (a *Manager) ChangeProvisioned(username string, provisioned bool) error { + if _, err := a.db.Exec(a.queries.updateUserProvisioned, provisioned, username); err != nil { + return err + } + return nil +} + +// ChangeSettings persists the user settings +func (a *Manager) ChangeSettings(userID string, prefs *Prefs) error { + b, err := json.Marshal(prefs) + if err != nil { + return err + } + if _, err := a.db.Exec(a.queries.updateUserPrefs, string(b), userID); err != nil { + return err + } + return nil +} + +// changeTierCode changes a user's tier using the tier code in the database +func (a *Manager) changeTierCode(username, tierCode string) error { + if _, err := a.db.Exec(a.queries.updateUserTier, tierCode, username); err != nil { + return err + } + return nil +} + +// resetTierCode removes the tier from the given user in the database +func (a *Manager) resetTierCode(username string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } + _, err := a.db.Exec(a.queries.deleteUserTier, username) + return err +} + +// UpdateStats updates statistics for one or more users in a single transaction +func (a *Manager) UpdateStats(stats map[string]*Stats) error { + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for userID, update := range stats { + if _, err := tx.Exec(a.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { + return err + } + } + return tx.Commit() +} + +func (a *Manager) readUser(rows *sql.Rows) (*User, error) { + defer rows.Close() + var id, username, hash, role, prefs, syncTopic string + var provisioned bool + var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString + var messages, emails, calls int64 + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + if !rows.Next() { + return nil, ErrUserNotFound + } + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + user := &User{ + ID: id, + Name: username, + Hash: hash, + Role: Role(role), + Prefs: &Prefs{}, + SyncTopic: syncTopic, + Provisioned: provisioned, + Stats: &Stats{ + Messages: messages, + Emails: emails, + Calls: calls, + }, + Billing: &Billing{ + StripeCustomerID: stripeCustomerID.String, // May be empty + StripeSubscriptionID: stripeSubscriptionID.String, // May be empty + StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty + StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty + StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero + StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero + }, + Deleted: deleted.Valid, + } + if err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil { + return nil, err + } + if tierCode.Valid { + // See readTier() when this is changed! + user.Tier = &Tier{ + ID: tierID.String, + Code: tierCode.String, + Name: tierName.String, + MessageLimit: messagesLimit.Int64, + MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, + EmailLimit: emailsLimit.Int64, + CallLimit: callsLimit.Int64, + ReservationLimit: reservationsLimit.Int64, + AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, + AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, + AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, + StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty + StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty + } + } + return user, nil +} + +// createToken creates a new token and prunes excess tokens if the count exceeds maxTokenCount. +// If maxTokenCount is 0, no pruning is performed. +func (a *Manager) createToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) { + tx, err := a.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err := tx.Exec(a.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil { + return nil, err + } + if maxTokenCount > 0 { + var tokenCount int + if err := tx.QueryRow(a.queries.selectTokenCount, userID).Scan(&tokenCount); err != nil { + return nil, err + } + if tokenCount > maxTokenCount { + if _, err := tx.Exec(a.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil { + return nil, err + } + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &Token{ + Value: token, + Label: label, + LastAccess: lastAccess, + LastOrigin: lastOrigin, + Expires: expires, + Provisioned: provisioned, + }, nil +} + +// Token returns a specific token for a user +func (a *Manager) Token(userID, token string) (*Token, error) { + rows, err := a.db.Query(a.queries.selectToken, userID, token) + if err != nil { + return nil, err + } + defer rows.Close() + return a.readToken(rows) +} + +// Tokens returns all existing tokens for the user with the given user ID +func (a *Manager) Tokens(userID string) ([]*Token, error) { + rows, err := a.db.Query(a.queries.selectTokens, userID) + if err != nil { + return nil, err + } + defer rows.Close() + tokens := make([]*Token, 0) + for { + token, err := a.readToken(rows) + if errors.Is(err, ErrTokenNotFound) { + break + } else if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + return tokens, nil +} + +// AllProvisionedTokens returns all provisioned tokens +func (a *Manager) AllProvisionedTokens() ([]*Token, error) { + rows, err := a.db.Query(a.queries.selectAllProvisionedTokens) + if err != nil { + return nil, err + } + defer rows.Close() + tokens := make([]*Token, 0) + for { + token, err := a.readToken(rows) + if errors.Is(err, ErrTokenNotFound) { + break + } else if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + return tokens, nil +} + +// changeToken updates a token's label and expiry time +func (a *Manager) changeToken(userID, token, label string, expires time.Time) error { + if _, err := a.db.Exec(a.queries.updateToken, label, expires.Unix(), userID, token); err != nil { + return err + } + return nil +} + +// UpdateTokenLastAccess updates the last access time and origin for one or more tokens in a single transaction +func (a *Manager) UpdateTokenLastAccess(updates map[string]*TokenUpdate) error { + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for token, update := range updates { + if _, err := tx.Exec(a.queries.updateTokenLastAccess, update.LastAccess.Unix(), update.LastOrigin.String(), token); err != nil { + return err + } + } + return tx.Commit() +} + +// removeToken deletes the token +func (a *Manager) removeToken(userID, token string) error { + if token == "" { + return errNoTokenProvided + } + if _, err := a.db.Exec(a.queries.deleteToken, userID, token); err != nil { + return err + } + return nil +} + +// RemoveProvisionedToken deletes a provisioned token by value, regardless of user +func (a *Manager) RemoveProvisionedToken(token string) error { + if token == "" { + return errNoTokenProvided + } + if _, err := a.db.Exec(a.queries.deleteProvisionedToken, token); err != nil { + return err + } + return nil +} + +// RemoveExpiredTokens deletes all expired tokens from the database +func (a *Manager) RemoveExpiredTokens() error { + if _, err := a.db.Exec(a.queries.deleteExpiredTokens, time.Now().Unix()); err != nil { + return err + } + return nil +} + +func (a *Manager) readToken(rows *sql.Rows) (*Token, error) { + var token, label, lastOrigin string + var lastAccess, expires int64 + var provisioned bool + if !rows.Next() { + return nil, ErrTokenNotFound + } + if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + lastOriginIP, err := netip.ParseAddr(lastOrigin) + if err != nil { + lastOriginIP = netip.IPv4Unspecified() + } + return &Token{ + Value: token, + Label: label, + LastAccess: time.Unix(lastAccess, 0), + LastOrigin: lastOriginIP, + Expires: time.Unix(expires, 0), + Provisioned: provisioned, + }, nil +} + +// AuthorizeTopicAccess returns the read/write permissions for the given username and topic. +// The found return value indicates whether an ACL entry was found at all. +// +// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user. +// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" +// - It also prioritizes write permissions over read permissions +func (a *Manager) AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) { + rows, err := a.db.Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic) + if err != nil { + return false, false, false, err + } + defer rows.Close() + if !rows.Next() { + return false, false, false, nil + } + if err := rows.Scan(&read, &write); err != nil { + return false, false, false, err + } else if err := rows.Err(); err != nil { + return false, false, false, err + } + return read, write, true, nil +} + +// AllGrants returns all user-specific access control entries, mapped to their respective user IDs +func (a *Manager) AllGrants() (map[string][]Grant, error) { + rows, err := a.db.Query(a.queries.selectUserAllAccess) + if err != nil { + return nil, err + } + defer rows.Close() + grants := make(map[string][]Grant, 0) + for rows.Next() { + var userID, topic string + var read, write, provisioned bool + if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + if _, ok := grants[userID]; !ok { + grants[userID] = make([]Grant, 0) + } + grants[userID] = append(grants[userID], Grant{ + TopicPattern: fromSQLWildcard(topic), + Permission: NewPermission(read, write), + Provisioned: provisioned, + }) + } + return grants, nil +} + +// Grants returns all user-specific access control entries +func (a *Manager) Grants(username string) ([]Grant, error) { + rows, err := a.db.Query(a.queries.selectUserAccess, username) + if err != nil { + return nil, err + } + defer rows.Close() + grants := make([]Grant, 0) + for rows.Next() { + var topic string + var read, write, provisioned bool + if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + grants = append(grants, Grant{ + TopicPattern: fromSQLWildcard(topic), + Permission: NewPermission(read, write), + Provisioned: provisioned, + }) + } + return grants, nil +} + +func (a *Manager) allowAccessTx(tx execer, username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) { + return ErrInvalidArgument + } + _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned) + return err +} + +func (a *Manager) resetUserAccessTx(tx execer, username string) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } + _, err := tx.Exec(a.queries.deleteUserAccess, username, username) + return err +} + +func (a *Manager) resetTopicAccessTx(tx execer, username, topicPattern string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { + return ErrInvalidArgument + } + _, err := tx.Exec(a.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern)) + return err +} + +// ResetAllProvisionedAccess removes all provisioned access control entries +func (a *Manager) ResetAllProvisionedAccess() error { + if _, err := a.db.Exec(a.queries.deleteUserAccessProvisioned); err != nil { + return err + } + return nil +} + +// AddReservation creates two access control entries for the given topic: one with full read/write +// access for the given user, and one for Everyone with the given permission. Both entries are +// created atomically in a single transaction. +func (a *Manager) AddReservation(username string, topic string, everyone Permission) error { + if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { + return ErrInvalidArgument + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := a.allowAccessTx(tx, username, topic, true, true, username, false); err != nil { + return err + } + if err := a.allowAccessTx(tx, Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username, false); err != nil { + return err + } + return tx.Commit() +} + +// RemoveReservations deletes the access control entries associated with the given username/topic, +// as well as all entries with Everyone/topic. All deletions are performed atomically in a single +// transaction. +func (a *Manager) RemoveReservations(username string, topics ...string) error { + if !AllowedUsername(username) || username == Everyone || len(topics) == 0 { + return ErrInvalidArgument + } + for _, topic := range topics { + if !AllowedTopic(topic) { + return ErrInvalidArgument + } + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, topic := range topics { + if err := a.resetTopicAccessTx(tx, username, topic); err != nil { + return err + } + if err := a.resetTopicAccessTx(tx, Everyone, topic); err != nil { + return err + } + } + return tx.Commit() +} + +// Reservations returns all user-owned topics, and the associated everyone-access +func (a *Manager) Reservations(username string) ([]Reservation, error) { + rows, err := a.db.Query(a.queries.selectUserReservations, Everyone, username) + if err != nil { + return nil, err + } + defer rows.Close() + reservations := make([]Reservation, 0) + for rows.Next() { + var topic string + var ownerRead, ownerWrite bool + var everyoneRead, everyoneWrite sql.NullBool + if err := rows.Scan(&topic, &ownerRead, &ownerWrite, &everyoneRead, &everyoneWrite); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + reservations = append(reservations, Reservation{ + Topic: fromSQLWildcard(topic), + Owner: NewPermission(ownerRead, ownerWrite), + Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool), + }) + } + return reservations, nil +} + +// HasReservation returns true if the given topic access is owned by the user +func (a *Manager) HasReservation(username, topic string) (bool, error) { + rows, err := a.db.Query(a.queries.selectUserHasReservation, username, escapeUnderscore(topic)) + if err != nil { + return false, err + } + defer rows.Close() + if !rows.Next() { + return false, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +// ReservationsCount returns the number of reservations owned by this user +func (a *Manager) ReservationsCount(username string) (int64, error) { + rows, err := a.db.Query(a.queries.selectUserReservationsCount, username) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int64 + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// ReservationOwner returns user ID of the user that owns this topic, or an empty string if it's not owned by anyone +func (a *Manager) ReservationOwner(topic string) (string, error) { + rows, err := a.db.Query(a.queries.selectUserReservationsOwner, escapeUnderscore(topic)) + if err != nil { + return "", err + } + defer rows.Close() + if !rows.Next() { + return "", nil + } + var ownerUserID string + if err := rows.Scan(&ownerUserID); err != nil { + return "", err + } + return ownerUserID, nil +} + +// OtherAccessCount returns the number of access entries for the given topic that are not owned by the user +func (a *Manager) OtherAccessCount(username, topic string) (int, error) { + rows, err := a.db.Query(a.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, errNoRows + } + var count int + if err := rows.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// AddTier creates a new tier in the database +func (a *Manager) AddTier(tier *Tier) error { + if tier.ID == "" { + tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) + } + if _, err := a.db.Exec(a.queries.insertTier, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { + return err + } + return nil +} + +// UpdateTier updates a tier's properties in the database +func (a *Manager) UpdateTier(tier *Tier) error { + if _, err := a.db.Exec(a.queries.updateTier, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { + return err + } + return nil +} + +// RemoveTier deletes the tier with the given code +func (a *Manager) RemoveTier(code string) error { + if !AllowedTier(code) { + return ErrInvalidArgument + } + // This fails if any user has this tier + if _, err := a.db.Exec(a.queries.deleteTier, code); err != nil { + return err + } + return nil +} + +// Tiers returns a list of all Tier structs +func (a *Manager) Tiers() ([]*Tier, error) { + rows, err := a.db.Query(a.queries.selectTiers) + if err != nil { + return nil, err + } + defer rows.Close() + tiers := make([]*Tier, 0) + for { + tier, err := a.readTier(rows) + if errors.Is(err, ErrTierNotFound) { + break + } else if err != nil { + return nil, err + } + tiers = append(tiers, tier) + } + return tiers, nil +} + +// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist +func (a *Manager) Tier(code string) (*Tier, error) { + rows, err := a.db.Query(a.queries.selectTierByCode, code) + if err != nil { + return nil, err + } + defer rows.Close() + return a.readTier(rows) +} + +// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist +func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { + rows, err := a.db.Query(a.queries.selectTierByPriceID, priceID, priceID) + if err != nil { + return nil, err + } + defer rows.Close() + return a.readTier(rows) +} + +func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { + var id, code, name string + var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 + if !rows.Next() { + return nil, ErrTierNotFound + } + if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + // When changed, note readUser() as well + return &Tier{ + ID: id, + Code: code, + Name: name, + MessageLimit: messagesLimit.Int64, + MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, + EmailLimit: emailsLimit.Int64, + CallLimit: callsLimit.Int64, + ReservationLimit: reservationsLimit.Int64, + AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, + AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, + AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, + StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty + StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty + }, nil +} + +// PhoneNumbers returns all phone numbers for the user with the given user ID +func (a *Manager) PhoneNumbers(userID string) ([]string, error) { + rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID) + if err != nil { + return nil, err + } + defer rows.Close() + phoneNumbers := make([]string, 0) + for { + phoneNumber, err := a.readPhoneNumber(rows) + if errors.Is(err, ErrPhoneNumberNotFound) { + break + } else if err != nil { + return nil, err + } + phoneNumbers = append(phoneNumbers, phoneNumber) + } + return phoneNumbers, nil +} + +// AddPhoneNumber adds a phone number to the user with the given user ID +func (a *Manager) AddPhoneNumber(userID, phoneNumber string) error { + if _, err := a.db.Exec(a.queries.insertPhoneNumber, userID, phoneNumber); err != nil { + if isUniqueConstraintError(err) { + return ErrPhoneNumberExists + } + return err + } + return nil +} + +// RemovePhoneNumber deletes a phone number from the user with the given user ID +func (a *Manager) RemovePhoneNumber(userID, phoneNumber string) error { + _, err := a.db.Exec(a.queries.deletePhoneNumber, userID, phoneNumber) + return err +} +func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { + var phoneNumber string + if !rows.Next() { + return "", ErrPhoneNumberNotFound + } + if err := rows.Scan(&phoneNumber); err != nil { + return "", err + } else if err := rows.Err(); err != nil { + return "", err + } + return phoneNumber, nil +} + +// ChangeBilling updates a user's billing fields +func (a *Manager) ChangeBilling(username string, billing *Billing) error { + if _, err := a.db.Exec(a.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { + return err + } + return nil +} + +// UserIDByUsername returns the user ID for the given username +func (a *Manager) UserIDByUsername(username string) (string, error) { + rows, err := a.db.Query(a.queries.selectUserIDFromUsername, username) + if err != nil { + return "", err + } + defer rows.Close() + if !rows.Next() { + return "", ErrUserNotFound + } + var userID string + if err := rows.Scan(&userID); err != nil { + return "", err + } + return userID, nil +} + +// isUniqueConstraintError checks if the error is a unique constraint violation for both SQLite and PostgreSQL +func isUniqueConstraintError(err error) bool { + errStr := err.Error() + return strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "23505") +} diff --git a/user/store_postgres.go b/user/manager_postgres.go similarity index 97% rename from user/store_postgres.go rename to user/manager_postgres.go index c4c9577c..e396fbea 100644 --- a/user/store_postgres.go +++ b/user/manager_postgres.go @@ -203,13 +203,14 @@ const ( ` ) -// NewPostgresStore creates a new PostgreSQL-backed user store using an existing database connection pool. -func NewPostgresStore(db *sql.DB) (Store, error) { +// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool. +func NewPostgresManager(db *sql.DB, config *Config) (*Manager, error) { if err := setupPostgres(db); err != nil { return nil, err } - return &commonStore{ - db: db, + manager := &Manager{ + config: config, + db: db, queries: storeQueries{ // User queries selectUserByID: postgresSelectUserByIDQuery, @@ -277,5 +278,9 @@ func NewPostgresStore(db *sql.DB) (Store, error) { // Billing queries updateBilling: postgresUpdateBillingQuery, }, - }, nil + } + if err := initManager(manager); err != nil { + return nil, err + } + return manager, nil } diff --git a/user/store_postgres_schema.go b/user/manager_postgres_schema.go similarity index 100% rename from user/store_postgres_schema.go rename to user/manager_postgres_schema.go diff --git a/user/store_sqlite.go b/user/manager_sqlite.go similarity index 98% rename from user/store_sqlite.go rename to user/manager_sqlite.go index c9d6d33f..7ca6e711 100644 --- a/user/store_sqlite.go +++ b/user/manager_sqlite.go @@ -201,8 +201,8 @@ const ( ` ) -// NewSQLiteStore creates a new SQLite-backed user store -func NewSQLiteStore(filename, startupQueries string) (Store, error) { +// NewSQLiteManager creates a new Manager backed by a SQLite database +func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager, error) { parentDir := filepath.Dir(filename) if !util.FileExists(parentDir) { return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir) @@ -217,8 +217,9 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { if err := runSQLiteStartupQueries(db, startupQueries); err != nil { return nil, err } - return &commonStore{ - db: db, + manager := &Manager{ + config: config, + db: db, queries: storeQueries{ selectUserByID: sqliteSelectUserByIDQuery, selectUserByName: sqliteSelectUserByNameQuery, @@ -275,5 +276,9 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { deletePhoneNumber: sqliteDeletePhoneNumberQuery, updateBilling: sqliteUpdateBillingQuery, }, - }, nil + } + if err := initManager(manager); err != nil { + return nil, err + } + return manager, nil } diff --git a/user/store_sqlite_schema.go b/user/manager_sqlite_schema.go similarity index 100% rename from user/store_sqlite_schema.go rename to user/manager_sqlite_schema.go diff --git a/user/manager_test.go b/user/manager_test.go index ea976a3f..8ba6fe3b 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -18,35 +18,36 @@ import ( const minBcryptTimingMillis = int64(40) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources -// newStoreFunc creates a Store. Calling it multiple times within the same test -// returns a new Store object pointing at the same underlying data (same SQLite -// file / same PostgreSQL schema), enabling close-and-reopen tests. -type newStoreFunc func() Store +// newManagerFunc creates a Manager with the given config. Calling it multiple +// times within the same test returns a new Manager pointing at the same +// underlying data (same SQLite file / same PostgreSQL schema), enabling +// close-and-reopen tests. +type newManagerFunc func(config *Config) *Manager -func forEachBackend(t *testing.T, f func(t *testing.T, newStore newStoreFunc)) { +func forEachBackend(t *testing.T, f func(t *testing.T, newManager newManagerFunc)) { t.Run("sqlite", func(t *testing.T) { dir := t.TempDir() - f(t, func() Store { - store, err := NewSQLiteStore(filepath.Join(dir, "user.db"), "") + f(t, func(config *Config) *Manager { + a, err := NewSQLiteManager(filepath.Join(dir, "user.db"), "", config) require.Nil(t, err) - return store + return a }) }) t.Run("postgres", func(t *testing.T) { schemaDSN := dbtest.CreateTestPostgresSchema(t) - f(t, func() Store { + f(t, func(config *Config) *Manager { pool, err := db.OpenPostgres(schemaDSN) require.Nil(t, err) - store, err := NewPostgresStore(pool) + a, err := NewPostgresManager(pool, config) require.Nil(t, err) - return store + return a }) }) } func TestManager_FullScenario_Default_DenyAll(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AddUser("john", "john", RoleUser, false)) @@ -162,8 +163,8 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) { func TestManager_Access_Order_LengthWriteRead(t *testing.T) { // This test validates issue #914 / #917, i.e. that write permissions are prioritized over read permissions, // and longer ACL rules are prioritized as well. - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AllowAccess("ben", "test*", PermissionReadWrite)) require.Nil(t, a.AllowAccess("ben", "*", PermissionRead)) @@ -177,8 +178,8 @@ func TestManager_Access_Order_LengthWriteRead(t *testing.T) { } func TestManager_AddUser_Invalid(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, false)) require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", false)) }) @@ -192,8 +193,8 @@ func TestManager_AddUser_Timing(t *testing.T) { } func TestManager_AddUser_And_Query(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) require.Nil(t, a.ChangeBilling("user", &Billing{ StripeCustomerID: "acct_123", @@ -219,8 +220,8 @@ func TestManager_AddUser_And_Query(t *testing.T) { } func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) // Create user, add reservations and token require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) @@ -262,8 +263,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { require.True(t, u.Deleted) // Backdate the deleted timestamp so RemoveDeletedUsers will prune the user - q := a.store.(*commonStore).queries.updateUserDeleted - _, err = testDB(a).Exec(q, time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID) + _, err = testDB(a).Exec(a.queries.updateUserDeleted, time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID) require.Nil(t, err) require.Nil(t, a.RemoveDeletedUsers()) @@ -273,8 +273,8 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { } func TestManager_CreateToken_Only_Lower(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) // Create user, add reservations and token require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) @@ -288,8 +288,8 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) { } func TestManager_UserManagement(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) @@ -381,8 +381,8 @@ func TestManager_UserManagement(t *testing.T) { } func TestManager_ChangePassword(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true)) @@ -407,8 +407,8 @@ func TestManager_ChangePassword(t *testing.T) { } func TestManager_ChangeRole(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) @@ -434,8 +434,8 @@ func TestManager_ChangeRole(t *testing.T) { } func TestManager_Reservations(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll)) @@ -506,8 +506,8 @@ func TestManager_Reservations(t *testing.T) { } func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddTier(&Tier{ Code: "pro", Name: "ntfy Pro", @@ -567,8 +567,8 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) { } func TestManager_Token_Valid(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) u, err := a.User("ben") @@ -613,8 +613,8 @@ func TestManager_Token_Valid(t *testing.T) { } func TestManager_Token_Invalid(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length @@ -628,16 +628,16 @@ func TestManager_Token_Invalid(t *testing.T) { } func TestManager_Token_NotFound(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) _, err := a.Token("u_bla", "notfound") require.Equal(t, ErrTokenNotFound, err) }) } func TestManager_Token_Expire(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) u, err := a.User("ben") @@ -686,8 +686,8 @@ func TestManager_Token_Expire(t *testing.T) { } func TestManager_Token_Extend(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) // Try to extend token for user without token @@ -716,8 +716,8 @@ func TestManager_Token_Extend(t *testing.T) { func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { // Tests that tokens are automatically deleted when the maximum number of tokens is reached - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) @@ -787,13 +787,13 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { } func TestManager_EnqueueStats_ResetStats(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { conf := &Config{ DefaultAccess: PermissionReadWrite, BcryptCost: bcrypt.MinCost, QueueWriterInterval: 1500 * time.Millisecond, } - a := newTestManagerFromStoreConfig(t, newStore, conf) + a := newTestManagerFromConfig(t, newManager, conf) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) // Baseline: No messages or emails @@ -835,13 +835,13 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) { } func TestManager_EnqueueTokenUpdate(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { conf := &Config{ DefaultAccess: PermissionReadWrite, BcryptCost: bcrypt.MinCost, QueueWriterInterval: 500 * time.Millisecond, } - a := newTestManagerFromStoreConfig(t, newStore, conf) + a := newTestManagerFromConfig(t, newManager, conf) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) // Create user and token @@ -874,13 +874,13 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) { } func TestManager_ChangeSettings(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { conf := &Config{ DefaultAccess: PermissionReadWrite, BcryptCost: bcrypt.MinCost, QueueWriterInterval: 1500 * time.Millisecond, } - a := newTestManagerFromStoreConfig(t, newStore, conf) + a := newTestManagerFromConfig(t, newManager, conf) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) // No settings @@ -921,8 +921,8 @@ func TestManager_ChangeSettings(t *testing.T) { } func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) // Create tier and user require.Nil(t, a.AddTier(&Tier{ @@ -1041,8 +1041,8 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { } func TestAccount_Tier_Create_With_ID(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddTier(&Tier{ ID: "ti_123", @@ -1056,8 +1056,8 @@ func TestAccount_Tier_Create_With_ID(t *testing.T) { } func TestManager_Tier_Change_And_Reset(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) // Create tier and user require.Nil(t, a.AddTier(&Tier{ @@ -1095,8 +1095,8 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) { } func TestUser_PhoneNumberAddListRemove(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) phil, err := a.User("phil") @@ -1122,8 +1122,8 @@ func TestUser_PhoneNumberAddListRemove(t *testing.T) { } func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) @@ -1137,8 +1137,8 @@ func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) { } func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AllowAccess(Everyone, "*_", PermissionRead)) require.Nil(t, a.AllowAccess(Everyone, "__*_", PermissionRead)) require.Nil(t, a.Authorize(nil, "allowed_", PermissionRead)) @@ -1151,8 +1151,8 @@ func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) { } func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { - a := newTestManager(t, newStore, PermissionDenyAll) + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) require.Nil(t, a.AllowAccess(Everyone, "mytopic_", PermissionReadWrite)) require.Nil(t, a.Authorize(nil, "mytopic_", PermissionRead)) require.Nil(t, a.Authorize(nil, "mytopic_", PermissionWrite)) @@ -1162,7 +1162,7 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { } func TestManager_WithProvisionedUsers(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { conf := &Config{ DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, @@ -1182,7 +1182,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) { }, }, } - a := newTestManagerFromStoreConfig(t, newStore, conf) + a := newTestManagerFromConfig(t, newManager, conf) // Manually add user require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false)) @@ -1218,7 +1218,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Update the token last access time and origin (so we can check that it is persisted) lastAccessTime := time.Now().Add(time.Hour) lastOrigin := netip.MustParseAddr("1.1.9.9") - err = a.store.UpdateTokenLastAccess(map[string]*TokenUpdate{tokens[0].Value: {LastAccess: lastAccessTime, LastOrigin: lastOrigin}}) + err = a.UpdateTokenLastAccess(map[string]*TokenUpdate{tokens[0].Value: {LastAccess: lastAccessTime, LastOrigin: lastOrigin}}) require.Nil(t, err) // Re-open the DB (second app start) @@ -1238,7 +1238,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) { {Value: "tk_u48wqendnkx9er21pqqcadlytbutx", Label: "Another token"}, }, } - a = newTestManagerFromStoreConfig(t, newStore, conf) + a = newTestManagerFromConfig(t, newManager, conf) // Check that the provisioned users are there users, err = a.Users() @@ -1277,7 +1277,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) { conf.Users = []*User{} conf.Access = map[string][]*Grant{} conf.Tokens = map[string][]*Token{} - a = newTestManagerFromStoreConfig(t, newStore, conf) + a = newTestManagerFromConfig(t, newManager, conf) // Check that the provisioned users are all gone users, err = a.Users() @@ -1314,7 +1314,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) { } func TestManager_WithProvisionedUsers_RemoveToken(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { conf := &Config{ DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, @@ -1328,7 +1328,7 @@ func TestManager_WithProvisionedUsers_RemoveToken(t *testing.T) { }, }, } - a := newTestManagerFromStoreConfig(t, newStore, conf) + a := newTestManagerFromConfig(t, newManager, conf) users, err := a.Users() require.Nil(t, err) @@ -1351,7 +1351,7 @@ func TestManager_WithProvisionedUsers_RemoveToken(t *testing.T) { {Value: "tk_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Label: "Token A"}, }, } - a = newTestManagerFromStoreConfig(t, newStore, conf) + a = newTestManagerFromConfig(t, newManager, conf) tokens, err = a.Tokens(philUserID) require.Nil(t, err) @@ -1361,7 +1361,7 @@ func TestManager_WithProvisionedUsers_RemoveToken(t *testing.T) { } func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { - forEachBackend(t, func(t *testing.T, newStore newStoreFunc) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { conf := &Config{ DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, @@ -1372,7 +1372,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { }, }, } - a := newTestManagerFromStoreConfig(t, newStore, conf) + a := newTestManagerFromConfig(t, newManager, conf) // Manually add user require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false)) @@ -1413,7 +1413,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { {TopicPattern: "stats", Permission: PermissionReadWrite}, }, } - a = newTestManagerFromStoreConfig(t, newStore, conf) + a = newTestManagerFromConfig(t, newManager, conf) // Check that the user was "upgraded" to a provisioned user users, err = a.Users() @@ -1696,39 +1696,714 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) { require.Nil(t, rows.Close()) } -func newTestManager(t *testing.T, newStore newStoreFunc, defaultAccess Permission) *Manager { - store := newStore() - a, err := NewManager(store, &Config{ +func newTestManager(t *testing.T, newManager newManagerFunc, defaultAccess Permission) *Manager { + a := newManager(&Config{ DefaultAccess: defaultAccess, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval, }) - require.Nil(t, err) t.Cleanup(func() { a.Close() }) return a } func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager { - store, err := NewSQLiteStore(filename, startupQueries) - require.Nil(t, err) - conf := &Config{ + a, err := NewSQLiteManager(filename, startupQueries, &Config{ DefaultAccess: defaultAccess, BcryptCost: bcryptCost, QueueWriterInterval: statsWriterInterval, - } - a, err := NewManager(store, conf) + }) require.Nil(t, err) return a } -func newTestManagerFromStoreConfig(t *testing.T, newStore newStoreFunc, conf *Config) *Manager { - store := newStore() - a, err := NewManager(store, conf) - require.Nil(t, err) +func newTestManagerFromConfig(t *testing.T, newManager newManagerFunc, conf *Config) *Manager { + a := newManager(conf) t.Cleanup(func() { a.Close() }) return a } func testDB(a *Manager) *sql.DB { - return a.store.(*commonStore).db + return a.db +} + +func forEachStoreBackend(t *testing.T, f func(t *testing.T, manager *Manager)) { + t.Run("sqlite", func(t *testing.T) { + manager, err := NewSQLiteManager(filepath.Join(t.TempDir(), "user.db"), "", &Config{}) + require.Nil(t, err) + t.Cleanup(func() { manager.Close() }) + f(t, manager) + }) + t.Run("postgres", func(t *testing.T) { + testDB := dbtest.CreateTestPostgres(t) + manager, err := NewPostgresManager(testDB, &Config{}) + require.Nil(t, err) + f(t, manager) + }) +} + +func TestStoreAddUser(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) + require.Equal(t, RoleUser, u.Role) + require.False(t, u.Provisioned) + require.NotEmpty(t, u.ID) + require.NotEmpty(t, u.SyncTopic) + }) +} + +func TestStoreAddUserAlreadyExists(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "pass1", RoleUser, false)) + require.Equal(t, ErrUserExists, manager.AddUser("phil", "pass2", RoleUser, false)) + }) +} + +func TestStoreRemoveUser(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) + + require.Nil(t, manager.RemoveUser("phil")) + _, err = manager.User("phil") + require.Equal(t, ErrUserNotFound, err) + }) +} + +func TestStoreUserByID(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleAdmin, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + u2, err := manager.UserByID(u.ID) + require.Nil(t, err) + require.Equal(t, u.Name, u2.Name) + require.Equal(t, u.ID, u2.ID) + }) +} + +func TestStoreUserByToken(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + tk, err := manager.CreateToken(u.ID, "test token", time.Now().Add(24*time.Hour), netip.MustParseAddr("1.2.3.4"), false) + require.Nil(t, err) + require.NotEmpty(t, tk.Value) + + u2, err := manager.UserByToken(tk.Value) + require.Nil(t, err) + require.Equal(t, "phil", u2.Name) + }) +} + +func TestStoreUserByStripeCustomer(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.ChangeBilling("phil", &Billing{ + StripeCustomerID: "cus_test123", + StripeSubscriptionID: "sub_test123", + })) + + u, err := manager.UserByStripeCustomer("cus_test123") + require.Nil(t, err) + require.Equal(t, "phil", u.Name) + require.Equal(t, "cus_test123", u.Billing.StripeCustomerID) + }) +} + +func TestStoreUsers(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AddUser("ben", "benpass", RoleAdmin, false)) + + users, err := manager.Users() + require.Nil(t, err) + require.True(t, len(users) >= 3) // phil, ben, and the everyone user + }) +} + +func TestStoreUsersCount(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + count, err := manager.UsersCount() + require.Nil(t, err) + require.True(t, count >= 1) // At least the everyone user + + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + count2, err := manager.UsersCount() + require.Nil(t, err) + require.Equal(t, count+1, count2) + }) +} + +func TestStoreChangePassword(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + require.NotEmpty(t, u.Hash) + + require.Nil(t, manager.ChangePassword("phil", "newpass", false)) + u, err = manager.User("phil") + require.Nil(t, err) + require.NotEmpty(t, u.Hash) + }) +} + +func TestStoreChangeRole(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + require.Equal(t, RoleUser, u.Role) + + require.Nil(t, manager.ChangeRole("phil", RoleAdmin)) + u, err = manager.User("phil") + require.Nil(t, err) + require.Equal(t, RoleAdmin, u.Role) + }) +} + +func TestStoreTokens(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + expires := time.Now().Add(24 * time.Hour) + origin := netip.MustParseAddr("9.9.9.9") + + tk, err := manager.CreateToken(u.ID, "my token", expires, origin, false) + require.Nil(t, err) + require.NotEmpty(t, tk.Value) + require.Equal(t, "my token", tk.Label) + + // Get single token + tk2, err := manager.Token(u.ID, tk.Value) + require.Nil(t, err) + require.Equal(t, tk.Value, tk2.Value) + require.Equal(t, "my token", tk2.Label) + + // Get all tokens + tokens, err := manager.Tokens(u.ID) + require.Nil(t, err) + require.Len(t, tokens, 1) + require.Equal(t, tk.Value, tokens[0].Value) + }) +} + +func TestStoreTokenChange(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + expires := time.Now().Add(time.Hour) + tk, err := manager.CreateToken(u.ID, "old label", expires, netip.MustParseAddr("1.2.3.4"), false) + require.Nil(t, err) + + newLabel := "new label" + newExpires := time.Now().Add(2 * time.Hour) + tk2, err := manager.ChangeToken(u.ID, tk.Value, &newLabel, &newExpires) + require.Nil(t, err) + require.Equal(t, "new label", tk2.Label) + require.Equal(t, newExpires.Unix(), tk2.Expires.Unix()) + }) +} + +func TestStoreTokenRemove(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + tk, err := manager.CreateToken(u.ID, "label", time.Now().Add(time.Hour), netip.MustParseAddr("1.2.3.4"), false) + require.Nil(t, err) + + require.Nil(t, manager.RemoveToken(u.ID, tk.Value)) + _, err = manager.Token(u.ID, tk.Value) + require.Equal(t, ErrTokenNotFound, err) + }) +} + +func TestStoreTokenRemoveExpired(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + // Create expired token and active token + tkExpired, err := manager.CreateToken(u.ID, "expired", time.Now().Add(-time.Hour), netip.MustParseAddr("1.2.3.4"), false) + require.Nil(t, err) + tkActive, err := manager.CreateToken(u.ID, "active", time.Now().Add(time.Hour), netip.MustParseAddr("1.2.3.4"), false) + require.Nil(t, err) + + require.Nil(t, manager.RemoveExpiredTokens()) + + // Expired token should be gone + _, err = manager.Token(u.ID, tkExpired.Value) + require.Equal(t, ErrTokenNotFound, err) + + // Active token should still exist + tk, err := manager.Token(u.ID, tkActive.Value) + require.Nil(t, err) + require.Equal(t, tkActive.Value, tk.Value) + }) +} + +func TestStoreTokenCreatePrunesExcess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + // Create several tokens + var tokenValues []string + for i := 0; i < 3; i++ { + tk, err := manager.CreateToken(u.ID, "label", time.Now().Add(time.Duration(i+1)*time.Hour), netip.MustParseAddr("1.2.3.4"), false) + require.Nil(t, err) + tokenValues = append(tokenValues, tk.Value) + } + + tokens, err := manager.Tokens(u.ID) + require.Nil(t, err) + require.True(t, len(tokens) >= 3) + }) +} + +func TestStoreTokenUpdateLastAccess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + tk, err := manager.CreateToken(u.ID, "label", time.Now().Add(time.Hour), netip.MustParseAddr("1.2.3.4"), false) + require.Nil(t, err) + + newTime := time.Now().Add(5 * time.Minute) + newOrigin := netip.MustParseAddr("5.5.5.5") + manager.EnqueueTokenUpdate(tk.Value, &TokenUpdate{LastAccess: newTime, LastOrigin: newOrigin}) + }) +} + +func TestStoreAllowAccess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + + require.Nil(t, manager.AllowAccess("phil", "mytopic", PermissionReadWrite)) + grants, err := manager.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.Equal(t, "mytopic", grants[0].TopicPattern) + require.True(t, grants[0].Permission.IsReadWrite()) + }) +} + +func TestStoreAllowAccessReadOnly(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + + require.Nil(t, manager.AllowAccess("phil", "announcements", PermissionRead)) + grants, err := manager.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.True(t, grants[0].Permission.IsRead()) + require.False(t, grants[0].Permission.IsWrite()) + }) +} + +func TestStoreResetAccess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AllowAccess("phil", "topic1", PermissionReadWrite)) + require.Nil(t, manager.AllowAccess("phil", "topic2", PermissionRead)) + + grants, err := manager.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 2) + + require.Nil(t, manager.ResetAccess("phil", "topic1")) + grants, err = manager.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 1) + require.Equal(t, "topic2", grants[0].TopicPattern) + }) +} + +func TestStoreResetAccessAll(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AllowAccess("phil", "topic1", PermissionReadWrite)) + require.Nil(t, manager.AllowAccess("phil", "topic2", PermissionRead)) + + require.Nil(t, manager.ResetAccess("phil", "")) + grants, err := manager.Grants("phil") + require.Nil(t, err) + require.Len(t, grants, 0) + }) +} + +func TestStoreAuthorizeTopicAccess(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AllowAccess("phil", "mytopic", PermissionReadWrite)) + + read, write, found, err := manager.AuthorizeTopicAccess("phil", "mytopic") + require.Nil(t, err) + require.True(t, found) + require.True(t, read) + require.True(t, write) + }) +} + +func TestStoreAuthorizeTopicAccessNotFound(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + + _, _, found, err := manager.AuthorizeTopicAccess("phil", "other") + require.Nil(t, err) + require.False(t, found) + }) +} + +func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AllowAccess("phil", "secret", PermissionDenyAll)) + + read, write, found, err := manager.AuthorizeTopicAccess("phil", "secret") + require.Nil(t, err) + require.True(t, found) + require.False(t, read) + require.False(t, write) + }) +} + +func TestStoreReservations(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionRead)) + + reservations, err := manager.Reservations("phil") + require.Nil(t, err) + require.Len(t, reservations, 1) + require.Equal(t, "mytopic", reservations[0].Topic) + require.True(t, reservations[0].Owner.IsReadWrite()) + require.True(t, reservations[0].Everyone.IsRead()) + require.False(t, reservations[0].Everyone.IsWrite()) + }) +} + +func TestStoreReservationsCount(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AddReservation("phil", "topic1", PermissionReadWrite)) + require.Nil(t, manager.AddReservation("phil", "topic2", PermissionReadWrite)) + + count, err := manager.ReservationsCount("phil") + require.Nil(t, err) + require.Equal(t, int64(2), count) + }) +} + +func TestStoreHasReservation(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite)) + + has, err := manager.HasReservation("phil", "mytopic") + require.Nil(t, err) + require.True(t, has) + + has, err = manager.HasReservation("phil", "other") + require.Nil(t, err) + require.False(t, has) + }) +} + +func TestStoreReservationOwner(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite)) + + owner, err := manager.ReservationOwner("mytopic") + require.Nil(t, err) + require.NotEmpty(t, owner) // Returns the user ID + + owner, err = manager.ReservationOwner("unowned") + require.Nil(t, err) + require.Empty(t, owner) + }) +} + +func TestStoreTiers(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + tier := &Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + MessageLimit: 5000, + MessageExpiryDuration: 24 * time.Hour, + EmailLimit: 100, + CallLimit: 10, + ReservationLimit: 20, + AttachmentFileSizeLimit: 10 * 1024 * 1024, + AttachmentTotalSizeLimit: 100 * 1024 * 1024, + AttachmentExpiryDuration: 48 * time.Hour, + AttachmentBandwidthLimit: 500 * 1024 * 1024, + } + require.Nil(t, manager.AddTier(tier)) + + // Get by code + t2, err := manager.Tier("pro") + require.Nil(t, err) + require.Equal(t, "ti_test", t2.ID) + require.Equal(t, "pro", t2.Code) + require.Equal(t, "Pro", t2.Name) + require.Equal(t, int64(5000), t2.MessageLimit) + require.Equal(t, int64(100), t2.EmailLimit) + require.Equal(t, int64(10), t2.CallLimit) + require.Equal(t, int64(20), t2.ReservationLimit) + + // List all tiers + tiers, err := manager.Tiers() + require.Nil(t, err) + require.Len(t, tiers, 1) + require.Equal(t, "pro", tiers[0].Code) + }) +} + +func TestStoreTierUpdate(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + tier := &Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, manager.AddTier(tier)) + + tier.Name = "Professional" + tier.MessageLimit = 9999 + require.Nil(t, manager.UpdateTier(tier)) + + t2, err := manager.Tier("pro") + require.Nil(t, err) + require.Equal(t, "Professional", t2.Name) + require.Equal(t, int64(9999), t2.MessageLimit) + }) +} + +func TestStoreTierRemove(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + tier := &Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, manager.AddTier(tier)) + + t2, err := manager.Tier("pro") + require.Nil(t, err) + require.Equal(t, "pro", t2.Code) + + require.Nil(t, manager.RemoveTier("pro")) + _, err = manager.Tier("pro") + require.Equal(t, ErrTierNotFound, err) + }) +} + +func TestStoreTierByStripePrice(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + tier := &Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + StripeMonthlyPriceID: "price_monthly", + StripeYearlyPriceID: "price_yearly", + } + require.Nil(t, manager.AddTier(tier)) + + t2, err := manager.TierByStripePrice("price_monthly") + require.Nil(t, err) + require.Equal(t, "pro", t2.Code) + + t3, err := manager.TierByStripePrice("price_yearly") + require.Nil(t, err) + require.Equal(t, "pro", t3.Code) + }) +} + +func TestStoreChangeTier(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + tier := &Tier{ + ID: "ti_test", + Code: "pro", + Name: "Pro", + } + require.Nil(t, manager.AddTier(tier)) + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.ChangeTier("phil", "pro")) + + u, err := manager.User("phil") + require.Nil(t, err) + require.NotNil(t, u.Tier) + require.Equal(t, "pro", u.Tier.Code) + }) +} + +func TestStorePhoneNumbers(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + require.Nil(t, manager.AddPhoneNumber(u.ID, "+1234567890")) + require.Nil(t, manager.AddPhoneNumber(u.ID, "+0987654321")) + + numbers, err := manager.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Len(t, numbers, 2) + + require.Nil(t, manager.RemovePhoneNumber(u.ID, "+1234567890")) + numbers, err = manager.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Len(t, numbers, 1) + require.Equal(t, "+0987654321", numbers[0]) + }) +} + +func TestStoreChangeSettings(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + lang := "de" + prefs := &Prefs{Language: &lang} + require.Nil(t, manager.ChangeSettings(u.ID, prefs)) + + u2, err := manager.User("phil") + require.Nil(t, err) + require.NotNil(t, u2.Prefs) + require.Equal(t, "de", *u2.Prefs.Language) + }) +} + +func TestStoreChangeBilling(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + + billing := &Billing{ + StripeCustomerID: "cus_123", + StripeSubscriptionID: "sub_456", + } + require.Nil(t, manager.ChangeBilling("phil", billing)) + + u, err := manager.User("phil") + require.Nil(t, err) + require.Equal(t, "cus_123", u.Billing.StripeCustomerID) + require.Equal(t, "sub_456", u.Billing.StripeSubscriptionID) + }) +} + +func TestStoreUpdateStats(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + stats := &Stats{Messages: 42, Emails: 3, Calls: 1} + require.Nil(t, manager.UpdateStats(map[string]*Stats{u.ID: stats})) + + u2, err := manager.User("phil") + require.Nil(t, err) + require.Equal(t, int64(42), u2.Stats.Messages) + require.Equal(t, int64(3), u2.Stats.Emails) + require.Equal(t, int64(1), u2.Stats.Calls) + }) +} + +func TestStoreResetStats(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + require.Nil(t, manager.UpdateStats(map[string]*Stats{u.ID: {Messages: 42, Emails: 3, Calls: 1}})) + require.Nil(t, manager.ResetStats()) + + u2, err := manager.User("phil") + require.Nil(t, err) + require.Equal(t, int64(0), u2.Stats.Messages) + require.Equal(t, int64(0), u2.Stats.Emails) + require.Equal(t, int64(0), u2.Stats.Calls) + }) +} + +func TestStoreMarkUserRemoved(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + require.Nil(t, manager.MarkUserRemoved(u)) + + u2, err := manager.User("phil") + require.Nil(t, err) + require.True(t, u2.Deleted) + }) +} + +func TestStoreRemoveDeletedUsers(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + u, err := manager.User("phil") + require.Nil(t, err) + + require.Nil(t, manager.MarkUserRemoved(u)) + + // RemoveDeletedUsers only removes users past the hard-delete duration (7 days). + // Immediately after marking, the user should still exist. + require.Nil(t, manager.RemoveDeletedUsers()) + u2, err := manager.User("phil") + require.Nil(t, err) + require.True(t, u2.Deleted) + }) +} + +func TestStoreAllGrants(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AddUser("ben", "benpass", RoleUser, false)) + phil, err := manager.User("phil") + require.Nil(t, err) + ben, err := manager.User("ben") + require.Nil(t, err) + + require.Nil(t, manager.AllowAccess("phil", "topic1", PermissionReadWrite)) + require.Nil(t, manager.AllowAccess("ben", "topic2", PermissionRead)) + + grants, err := manager.AllGrants() + require.Nil(t, err) + require.Contains(t, grants, phil.ID) + require.Contains(t, grants, ben.ID) + }) +} + +func TestStoreOtherAccessCount(t *testing.T) { + forEachStoreBackend(t, func(t *testing.T, manager *Manager) { + require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) + require.Nil(t, manager.AddUser("ben", "benpass", RoleUser, false)) + require.Nil(t, manager.AddReservation("ben", "mytopic", PermissionReadWrite)) + + count, err := manager.OtherAccessCount("phil", "mytopic") + require.Nil(t, err) + require.Equal(t, 2, count) // ben's owner entry + everyone entry + }) } diff --git a/user/store.go b/user/store.go deleted file mode 100644 index 4460f181..00000000 --- a/user/store.go +++ /dev/null @@ -1,1059 +0,0 @@ -package user - -import ( - "database/sql" - "encoding/json" - "errors" - "net/netip" - "strings" - "time" - - "heckel.io/ntfy/v2/payments" - "heckel.io/ntfy/v2/util" -) - -// Store is the interface for a user database store -type Store interface { - // User operations - UserByID(id string) (*User, error) - User(username string) (*User, error) - UserByToken(token string) (*User, error) - UserByStripeCustomer(customerID string) (*User, error) - UserIDByUsername(username string) (string, error) - Users() ([]*User, error) - UsersCount() (int64, error) - AddUser(username, hash string, role Role, provisioned bool) error - RemoveUser(username string) error - MarkUserRemoved(userID, username string) error - RemoveDeletedUsers() error - ChangePassword(username, hash string) error - ChangeRole(username string, role Role) error - ChangeProvisioned(username string, provisioned bool) error - ChangeSettings(userID string, prefs *Prefs) error - ChangeTier(username, tierCode string) error - ResetTier(username string) error - UpdateStats(stats map[string]*Stats) error - ResetStats() error - - // Token operations - CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) - Token(userID, token string) (*Token, error) - Tokens(userID string) ([]*Token, error) - AllProvisionedTokens() ([]*Token, error) - ChangeToken(userID, token, label string, expires time.Time) error - UpdateTokenLastAccess(updates map[string]*TokenUpdate) error - RemoveToken(userID, token string) error - RemoveProvisionedToken(token string) error - RemoveExpiredTokens() error - - // Access operations - AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) - AllGrants() (map[string][]Grant, error) - Grants(username string) ([]Grant, error) - AllowAccess(username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error - ResetAccess(username, topicPattern string) error - ResetAllProvisionedAccess() error - AddReservation(username, topic string, everyone Permission) error - RemoveReservations(username string, topics ...string) error - Reservations(username string) ([]Reservation, error) - HasReservation(username, topic string) (bool, error) - ReservationsCount(username string) (int64, error) - ReservationOwner(topic string) (string, error) - OtherAccessCount(username, topic string) (int, error) - - // Tier operations - AddTier(tier *Tier) error - UpdateTier(tier *Tier) error - RemoveTier(code string) error - Tiers() ([]*Tier, error) - Tier(code string) (*Tier, error) - TierByStripePrice(priceID string) (*Tier, error) - - // Phone operations - PhoneNumbers(userID string) ([]string, error) - AddPhoneNumber(userID, phoneNumber string) error - RemovePhoneNumber(userID, phoneNumber string) error - - // Other stuff - ChangeBilling(username string, billing *Billing) error - Close() error -} - -// storeQueries holds the database-specific SQL queries -type storeQueries struct { - // User queries - selectUserByID string - selectUserByName string - selectUserByToken string - selectUserByStripeCustomerID string - selectUsernames string - selectUserCount string - selectUserIDFromUsername string - insertUser string - updateUserPass string - updateUserRole string - updateUserProvisioned string - updateUserPrefs string - updateUserStats string - updateUserStatsResetAll string - updateUserTier string - updateUserDeleted string - deleteUser string - deleteUserTier string - deleteUsersMarked string - // Access queries - selectTopicPerms string - selectUserAllAccess string - selectUserAccess string - selectUserReservations string - selectUserReservationsCount string - selectUserReservationsOwner string - selectUserHasReservation string - selectOtherAccessCount string - upsertUserAccess string - deleteUserAccess string - deleteUserAccessProvisioned string - deleteTopicAccess string - deleteAllAccess string - // Token queries - selectToken string - selectTokens string - selectTokenCount string - selectAllProvisionedTokens string - upsertToken string - updateToken string - updateTokenLastAccess string - deleteToken string - deleteProvisionedToken string - deleteAllToken string - deleteExpiredTokens string - deleteExcessTokens string - // Tier queries - insertTier string - selectTiers string - selectTierByCode string - selectTierByPriceID string - updateTier string - deleteTier string - // Phone queries - selectPhoneNumbers string - insertPhoneNumber string - deletePhoneNumber string - // Billing queries - updateBilling string -} - -// execer is satisfied by both *sql.DB and *sql.Tx, allowing helper methods -// to be used both standalone and within a transaction. -type execer interface { - Exec(query string, args ...any) (sql.Result, error) -} - -// commonStore implements store operations that work across database backends -type commonStore struct { - db *sql.DB - queries storeQueries -} - -// User returns the user with the given username if it exists, or ErrUserNotFound otherwise -func (s *commonStore) User(username string) (*User, error) { - rows, err := s.db.Query(s.queries.selectUserByName, username) - if err != nil { - return nil, err - } - return s.readUser(rows) -} - -// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise -func (s *commonStore) UserByID(id string) (*User, error) { - rows, err := s.db.Query(s.queries.selectUserByID, id) - if err != nil { - return nil, err - } - return s.readUser(rows) -} - -// UserByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise -func (s *commonStore) UserByToken(token string) (*User, error) { - rows, err := s.db.Query(s.queries.selectUserByToken, token, time.Now().Unix()) - if err != nil { - return nil, err - } - return s.readUser(rows) -} - -// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise -func (s *commonStore) UserByStripeCustomer(customerID string) (*User, error) { - rows, err := s.db.Query(s.queries.selectUserByStripeCustomerID, customerID) - if err != nil { - return nil, err - } - return s.readUser(rows) -} - -// Users returns a list of users -func (s *commonStore) Users() ([]*User, error) { - rows, err := s.db.Query(s.queries.selectUsernames) - if err != nil { - return nil, err - } - defer rows.Close() - usernames := make([]string, 0) - for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - usernames = append(usernames, username) - } - rows.Close() - users := make([]*User, 0) - for _, username := range usernames { - user, err := s.User(username) - if err != nil { - return nil, err - } - users = append(users, user) - } - return users, nil -} - -// UsersCount returns the number of users in the database -func (s *commonStore) UsersCount() (int64, error) { - rows, err := s.db.Query(s.queries.selectUserCount) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - var count int64 - if err := rows.Scan(&count); err != nil { - return 0, err - } - return count, nil -} - -// AddUser adds a user with the given username, password hash and role -func (s *commonStore) AddUser(username, hash string, role Role, provisioned bool) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - userID := util.RandomStringPrefix(userIDPrefix, userIDLength) - syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) - now := time.Now().Unix() - if _, err := s.db.Exec(s.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil { - if isUniqueConstraintError(err) { - return ErrUserExists - } - return err - } - return nil -} - -// RemoveUser deletes the user with the given username -func (s *commonStore) RemoveUser(username string) error { - if !AllowedUsername(username) { - return ErrInvalidArgument - } - // Rows in user_access, user_token, etc. are deleted via foreign keys - if _, err := s.db.Exec(s.queries.deleteUser, username); err != nil { - return err - } - return nil -} - -// MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens -func (s *commonStore) MarkUserRemoved(userID, username string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := s.resetUserAccessTx(tx, username); err != nil { - return err - } - if _, err := tx.Exec(s.queries.deleteAllToken, userID); err != nil { - return err - } - deletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix() - if _, err := tx.Exec(s.queries.updateUserDeleted, deletedTime, userID); err != nil { - return err - } - return tx.Commit() -} - -// RemoveDeletedUsers deletes all users that have been marked deleted -func (s *commonStore) RemoveDeletedUsers() error { - if _, err := s.db.Exec(s.queries.deleteUsersMarked, time.Now().Unix()); err != nil { - return err - } - return nil -} - -// ChangePassword changes a user's password -func (s *commonStore) ChangePassword(username, hash string) error { - if _, err := s.db.Exec(s.queries.updateUserPass, hash, username); err != nil { - return err - } - return nil -} - -// ChangeRole changes a user's role -func (s *commonStore) ChangeRole(username string, role Role) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(s.queries.updateUserRole, string(role), username); err != nil { - return err - } - // If changing to admin, remove all access entries - if role == RoleAdmin { - if err := s.resetUserAccessTx(tx, username); err != nil { - return err - } - } - return tx.Commit() -} - -// ChangeProvisioned changes the provisioned status of a user -func (s *commonStore) ChangeProvisioned(username string, provisioned bool) error { - if _, err := s.db.Exec(s.queries.updateUserProvisioned, provisioned, username); err != nil { - return err - } - return nil -} - -// ChangeSettings persists the user settings -func (s *commonStore) ChangeSettings(userID string, prefs *Prefs) error { - b, err := json.Marshal(prefs) - if err != nil { - return err - } - if _, err := s.db.Exec(s.queries.updateUserPrefs, string(b), userID); err != nil { - return err - } - return nil -} - -// ChangeTier changes a user's tier using the tier code -func (s *commonStore) ChangeTier(username, tierCode string) error { - if _, err := s.db.Exec(s.queries.updateUserTier, tierCode, username); err != nil { - return err - } - return nil -} - -// ResetTier removes the tier from the given user -func (s *commonStore) ResetTier(username string) error { - if !AllowedUsername(username) && username != Everyone && username != "" { - return ErrInvalidArgument - } - _, err := s.db.Exec(s.queries.deleteUserTier, username) - return err -} - -// UpdateStats updates statistics for one or more users in a single transaction -func (s *commonStore) UpdateStats(stats map[string]*Stats) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for userID, update := range stats { - if _, err := tx.Exec(s.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { - return err - } - } - return tx.Commit() -} - -// ResetStats resets all user stats in the user database -func (s *commonStore) ResetStats() error { - if _, err := s.db.Exec(s.queries.updateUserStatsResetAll); err != nil { - return err - } - return nil -} - -func (s *commonStore) readUser(rows *sql.Rows) (*User, error) { - defer rows.Close() - var id, username, hash, role, prefs, syncTopic string - var provisioned bool - var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString - var messages, emails, calls int64 - var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 - if !rows.Next() { - return nil, ErrUserNotFound - } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - user := &User{ - ID: id, - Name: username, - Hash: hash, - Role: Role(role), - Prefs: &Prefs{}, - SyncTopic: syncTopic, - Provisioned: provisioned, - Stats: &Stats{ - Messages: messages, - Emails: emails, - Calls: calls, - }, - Billing: &Billing{ - StripeCustomerID: stripeCustomerID.String, // May be empty - StripeSubscriptionID: stripeSubscriptionID.String, // May be empty - StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty - StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty - StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero - StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero - }, - Deleted: deleted.Valid, - } - if err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil { - return nil, err - } - if tierCode.Valid { - // See readTier() when this is changed! - user.Tier = &Tier{ - ID: tierID.String, - Code: tierCode.String, - Name: tierName.String, - MessageLimit: messagesLimit.Int64, - MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, - EmailLimit: emailsLimit.Int64, - CallLimit: callsLimit.Int64, - ReservationLimit: reservationsLimit.Int64, - AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, - AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, - AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, - AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, - StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty - StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty - } - } - return user, nil -} - -// CreateToken creates a new token and prunes excess tokens if the count exceeds maxTokenCount. -// If maxTokenCount is 0, no pruning is performed. -func (s *commonStore) CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) { - tx, err := s.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - if _, err := tx.Exec(s.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil { - return nil, err - } - if maxTokenCount > 0 { - var tokenCount int - if err := tx.QueryRow(s.queries.selectTokenCount, userID).Scan(&tokenCount); err != nil { - return nil, err - } - if tokenCount > maxTokenCount { - if _, err := tx.Exec(s.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil { - return nil, err - } - } - } - if err := tx.Commit(); err != nil { - return nil, err - } - return &Token{ - Value: token, - Label: label, - LastAccess: lastAccess, - LastOrigin: lastOrigin, - Expires: expires, - Provisioned: provisioned, - }, nil -} - -// Token returns a specific token for a user -func (s *commonStore) Token(userID, token string) (*Token, error) { - rows, err := s.db.Query(s.queries.selectToken, userID, token) - if err != nil { - return nil, err - } - defer rows.Close() - return s.readToken(rows) -} - -// Tokens returns all existing tokens for the user with the given user ID -func (s *commonStore) Tokens(userID string) ([]*Token, error) { - rows, err := s.db.Query(s.queries.selectTokens, userID) - if err != nil { - return nil, err - } - defer rows.Close() - tokens := make([]*Token, 0) - for { - token, err := s.readToken(rows) - if errors.Is(err, ErrTokenNotFound) { - break - } else if err != nil { - return nil, err - } - tokens = append(tokens, token) - } - return tokens, nil -} - -// AllProvisionedTokens returns all provisioned tokens -func (s *commonStore) AllProvisionedTokens() ([]*Token, error) { - rows, err := s.db.Query(s.queries.selectAllProvisionedTokens) - if err != nil { - return nil, err - } - defer rows.Close() - tokens := make([]*Token, 0) - for { - token, err := s.readToken(rows) - if errors.Is(err, ErrTokenNotFound) { - break - } else if err != nil { - return nil, err - } - tokens = append(tokens, token) - } - return tokens, nil -} - -// ChangeToken updates a token's label and expiry time -func (s *commonStore) ChangeToken(userID, token, label string, expires time.Time) error { - if _, err := s.db.Exec(s.queries.updateToken, label, expires.Unix(), userID, token); err != nil { - return err - } - return nil -} - -// UpdateTokenLastAccess updates the last access time and origin for one or more tokens in a single transaction -func (s *commonStore) UpdateTokenLastAccess(updates map[string]*TokenUpdate) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for token, update := range updates { - if _, err := tx.Exec(s.queries.updateTokenLastAccess, update.LastAccess.Unix(), update.LastOrigin.String(), token); err != nil { - return err - } - } - return tx.Commit() -} - -// RemoveToken deletes the token -func (s *commonStore) RemoveToken(userID, token string) error { - if token == "" { - return errNoTokenProvided - } - if _, err := s.db.Exec(s.queries.deleteToken, userID, token); err != nil { - return err - } - return nil -} - -// RemoveProvisionedToken deletes a provisioned token by value, regardless of user -func (s *commonStore) RemoveProvisionedToken(token string) error { - if token == "" { - return errNoTokenProvided - } - if _, err := s.db.Exec(s.queries.deleteProvisionedToken, token); err != nil { - return err - } - return nil -} - -// RemoveExpiredTokens deletes all expired tokens from the database -func (s *commonStore) RemoveExpiredTokens() error { - if _, err := s.db.Exec(s.queries.deleteExpiredTokens, time.Now().Unix()); err != nil { - return err - } - return nil -} - -func (s *commonStore) readToken(rows *sql.Rows) (*Token, error) { - var token, label, lastOrigin string - var lastAccess, expires int64 - var provisioned bool - if !rows.Next() { - return nil, ErrTokenNotFound - } - if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - lastOriginIP, err := netip.ParseAddr(lastOrigin) - if err != nil { - lastOriginIP = netip.IPv4Unspecified() - } - return &Token{ - Value: token, - Label: label, - LastAccess: time.Unix(lastAccess, 0), - LastOrigin: lastOriginIP, - Expires: time.Unix(expires, 0), - Provisioned: provisioned, - }, nil -} - -// AuthorizeTopicAccess returns the read/write permissions for the given username and topic. -// The found return value indicates whether an ACL entry was found at all. -// -// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user. -// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" -// - It also prioritizes write permissions over read permissions -func (s *commonStore) AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) { - rows, err := s.db.Query(s.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic) - if err != nil { - return false, false, false, err - } - defer rows.Close() - if !rows.Next() { - return false, false, false, nil - } - if err := rows.Scan(&read, &write); err != nil { - return false, false, false, err - } else if err := rows.Err(); err != nil { - return false, false, false, err - } - return read, write, true, nil -} - -// AllGrants returns all user-specific access control entries, mapped to their respective user IDs -func (s *commonStore) AllGrants() (map[string][]Grant, error) { - rows, err := s.db.Query(s.queries.selectUserAllAccess) - if err != nil { - return nil, err - } - defer rows.Close() - grants := make(map[string][]Grant, 0) - for rows.Next() { - var userID, topic string - var read, write, provisioned bool - if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - if _, ok := grants[userID]; !ok { - grants[userID] = make([]Grant, 0) - } - grants[userID] = append(grants[userID], Grant{ - TopicPattern: fromSQLWildcard(topic), - Permission: NewPermission(read, write), - Provisioned: provisioned, - }) - } - return grants, nil -} - -// Grants returns all user-specific access control entries -func (s *commonStore) Grants(username string) ([]Grant, error) { - rows, err := s.db.Query(s.queries.selectUserAccess, username) - if err != nil { - return nil, err - } - defer rows.Close() - grants := make([]Grant, 0) - for rows.Next() { - var topic string - var read, write, provisioned bool - if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - grants = append(grants, Grant{ - TopicPattern: fromSQLWildcard(topic), - Permission: NewPermission(read, write), - Provisioned: provisioned, - }) - } - return grants, nil -} - -// AllowAccess adds or updates an entry in the access control list -func (s *commonStore) AllowAccess(username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { - return s.allowAccessTx(s.db, username, topicPattern, read, write, ownerUsername, provisioned) -} - -func (s *commonStore) allowAccessTx(tx execer, username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { - if !AllowedUsername(username) && username != Everyone { - return ErrInvalidArgument - } else if !AllowedTopicPattern(topicPattern) { - return ErrInvalidArgument - } - _, err := tx.Exec(s.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned) - return err -} - -// ResetAccess removes an access control list entry -func (s *commonStore) ResetAccess(username, topicPattern string) error { - if username == "" && topicPattern == "" { - _, err := s.db.Exec(s.queries.deleteAllAccess) - return err - } else if topicPattern == "" { - return s.resetUserAccessTx(s.db, username) - } - return s.resetTopicAccessTx(s.db, username, topicPattern) -} - -func (s *commonStore) resetUserAccessTx(tx execer, username string) error { - if !AllowedUsername(username) && username != Everyone { - return ErrInvalidArgument - } - _, err := tx.Exec(s.queries.deleteUserAccess, username, username) - return err -} - -func (s *commonStore) resetTopicAccessTx(tx execer, username, topicPattern string) error { - if !AllowedUsername(username) && username != Everyone && username != "" { - return ErrInvalidArgument - } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { - return ErrInvalidArgument - } - _, err := tx.Exec(s.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern)) - return err -} - -// ResetAllProvisionedAccess removes all provisioned access control entries -func (s *commonStore) ResetAllProvisionedAccess() error { - if _, err := s.db.Exec(s.queries.deleteUserAccessProvisioned); err != nil { - return err - } - return nil -} - -// AddReservation creates two access control entries for the given topic: one with full read/write -// access for the given user, and one for Everyone with the given permission. Both entries are -// created atomically in a single transaction. -func (s *commonStore) AddReservation(username, topic string, everyone Permission) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := s.allowAccessTx(tx, username, topic, true, true, username, false); err != nil { - return err - } - if err := s.allowAccessTx(tx, Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username, false); err != nil { - return err - } - return tx.Commit() -} - -// RemoveReservations deletes the access control entries associated with the given username/topic, -// as well as all entries with Everyone/topic. All deletions are performed atomically in a single -// transaction. -func (s *commonStore) RemoveReservations(username string, topics ...string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, topic := range topics { - if err := s.resetTopicAccessTx(tx, username, topic); err != nil { - return err - } - if err := s.resetTopicAccessTx(tx, Everyone, topic); err != nil { - return err - } - } - return tx.Commit() -} - -// Reservations returns all user-owned topics, and the associated everyone-access -func (s *commonStore) Reservations(username string) ([]Reservation, error) { - rows, err := s.db.Query(s.queries.selectUserReservations, Everyone, username) - if err != nil { - return nil, err - } - defer rows.Close() - reservations := make([]Reservation, 0) - for rows.Next() { - var topic string - var ownerRead, ownerWrite bool - var everyoneRead, everyoneWrite sql.NullBool - if err := rows.Scan(&topic, &ownerRead, &ownerWrite, &everyoneRead, &everyoneWrite); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - reservations = append(reservations, Reservation{ - Topic: fromSQLWildcard(topic), - Owner: NewPermission(ownerRead, ownerWrite), - Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool), - }) - } - return reservations, nil -} - -// HasReservation returns true if the given topic access is owned by the user -func (s *commonStore) HasReservation(username, topic string) (bool, error) { - rows, err := s.db.Query(s.queries.selectUserHasReservation, username, escapeUnderscore(topic)) - if err != nil { - return false, err - } - defer rows.Close() - if !rows.Next() { - return false, errNoRows - } - var count int64 - if err := rows.Scan(&count); err != nil { - return false, err - } - return count > 0, nil -} - -// ReservationsCount returns the number of reservations owned by this user -func (s *commonStore) ReservationsCount(username string) (int64, error) { - rows, err := s.db.Query(s.queries.selectUserReservationsCount, username) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - var count int64 - if err := rows.Scan(&count); err != nil { - return 0, err - } - return count, nil -} - -// ReservationOwner returns user ID of the user that owns this topic, or an empty string if it's not owned by anyone -func (s *commonStore) ReservationOwner(topic string) (string, error) { - rows, err := s.db.Query(s.queries.selectUserReservationsOwner, escapeUnderscore(topic)) - if err != nil { - return "", err - } - defer rows.Close() - if !rows.Next() { - return "", nil - } - var ownerUserID string - if err := rows.Scan(&ownerUserID); err != nil { - return "", err - } - return ownerUserID, nil -} - -// OtherAccessCount returns the number of access entries for the given topic that are not owned by the user -func (s *commonStore) OtherAccessCount(username, topic string) (int, error) { - rows, err := s.db.Query(s.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - var count int - if err := rows.Scan(&count); err != nil { - return 0, err - } - return count, nil -} - -// AddTier creates a new tier in the database -func (s *commonStore) AddTier(tier *Tier) error { - if tier.ID == "" { - tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) - } - if _, err := s.db.Exec(s.queries.insertTier, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { - return err - } - return nil -} - -// UpdateTier updates a tier's properties in the database -func (s *commonStore) UpdateTier(tier *Tier) error { - if _, err := s.db.Exec(s.queries.updateTier, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { - return err - } - return nil -} - -// RemoveTier deletes the tier with the given code -func (s *commonStore) RemoveTier(code string) error { - if !AllowedTier(code) { - return ErrInvalidArgument - } - // This fails if any user has this tier - if _, err := s.db.Exec(s.queries.deleteTier, code); err != nil { - return err - } - return nil -} - -// Tiers returns a list of all Tier structs -func (s *commonStore) Tiers() ([]*Tier, error) { - rows, err := s.db.Query(s.queries.selectTiers) - if err != nil { - return nil, err - } - defer rows.Close() - tiers := make([]*Tier, 0) - for { - tier, err := s.readTier(rows) - if errors.Is(err, ErrTierNotFound) { - break - } else if err != nil { - return nil, err - } - tiers = append(tiers, tier) - } - return tiers, nil -} - -// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist -func (s *commonStore) Tier(code string) (*Tier, error) { - rows, err := s.db.Query(s.queries.selectTierByCode, code) - if err != nil { - return nil, err - } - defer rows.Close() - return s.readTier(rows) -} - -// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist -func (s *commonStore) TierByStripePrice(priceID string) (*Tier, error) { - rows, err := s.db.Query(s.queries.selectTierByPriceID, priceID, priceID) - if err != nil { - return nil, err - } - defer rows.Close() - return s.readTier(rows) -} - -func (s *commonStore) readTier(rows *sql.Rows) (*Tier, error) { - var id, code, name string - var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString - var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 - if !rows.Next() { - return nil, ErrTierNotFound - } - if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - // When changed, note readUser() as well - return &Tier{ - ID: id, - Code: code, - Name: name, - MessageLimit: messagesLimit.Int64, - MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, - EmailLimit: emailsLimit.Int64, - CallLimit: callsLimit.Int64, - ReservationLimit: reservationsLimit.Int64, - AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, - AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, - AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, - AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, - StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty - StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty - }, nil -} - -// PhoneNumbers returns all phone numbers for the user with the given user ID -func (s *commonStore) PhoneNumbers(userID string) ([]string, error) { - rows, err := s.db.Query(s.queries.selectPhoneNumbers, userID) - if err != nil { - return nil, err - } - defer rows.Close() - phoneNumbers := make([]string, 0) - for { - phoneNumber, err := s.readPhoneNumber(rows) - if errors.Is(err, ErrPhoneNumberNotFound) { - break - } else if err != nil { - return nil, err - } - phoneNumbers = append(phoneNumbers, phoneNumber) - } - return phoneNumbers, nil -} - -// AddPhoneNumber adds a phone number to the user with the given user ID -func (s *commonStore) AddPhoneNumber(userID, phoneNumber string) error { - if _, err := s.db.Exec(s.queries.insertPhoneNumber, userID, phoneNumber); err != nil { - if isUniqueConstraintError(err) { - return ErrPhoneNumberExists - } - return err - } - return nil -} - -// RemovePhoneNumber deletes a phone number from the user with the given user ID -func (s *commonStore) RemovePhoneNumber(userID, phoneNumber string) error { - _, err := s.db.Exec(s.queries.deletePhoneNumber, userID, phoneNumber) - return err -} -func (s *commonStore) readPhoneNumber(rows *sql.Rows) (string, error) { - var phoneNumber string - if !rows.Next() { - return "", ErrPhoneNumberNotFound - } - if err := rows.Scan(&phoneNumber); err != nil { - return "", err - } else if err := rows.Err(); err != nil { - return "", err - } - return phoneNumber, nil -} - -// ChangeBilling updates a user's billing fields -func (s *commonStore) ChangeBilling(username string, billing *Billing) error { - if _, err := s.db.Exec(s.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { - return err - } - return nil -} - -// UserIDByUsername returns the user ID for the given username -func (s *commonStore) UserIDByUsername(username string) (string, error) { - rows, err := s.db.Query(s.queries.selectUserIDFromUsername, username) - if err != nil { - return "", err - } - defer rows.Close() - if !rows.Next() { - return "", ErrUserNotFound - } - var userID string - if err := rows.Scan(&userID); err != nil { - return "", err - } - return userID, nil -} - -// Close closes the underlying database -func (s *commonStore) Close() error { - return s.db.Close() -} - -// isUniqueConstraintError checks if the error is a unique constraint violation for both SQLite and PostgreSQL -func isUniqueConstraintError(err error) bool { - errStr := err.Error() - return strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "23505") -} diff --git a/user/store_test.go b/user/store_test.go deleted file mode 100644 index 6ed88722..00000000 --- a/user/store_test.go +++ /dev/null @@ -1,713 +0,0 @@ -package user_test - -import ( - "net/netip" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/require" - dbtest "heckel.io/ntfy/v2/db/test" - "heckel.io/ntfy/v2/user" -) - -func forEachStoreBackend(t *testing.T, f func(t *testing.T, store user.Store)) { - t.Run("sqlite", func(t *testing.T) { - store, err := user.NewSQLiteStore(filepath.Join(t.TempDir(), "user.db"), "") - require.Nil(t, err) - t.Cleanup(func() { store.Close() }) - f(t, store) - }) - t.Run("postgres", func(t *testing.T) { - testDB := dbtest.CreateTestPostgres(t) - store, err := user.NewPostgresStore(testDB) - require.Nil(t, err) - f(t, store) - }) -} - -func TestStoreAddUser(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, "phil", u.Name) - require.Equal(t, user.RoleUser, u.Role) - require.False(t, u.Provisioned) - require.NotEmpty(t, u.ID) - require.NotEmpty(t, u.SyncTopic) - }) -} - -func TestStoreAddUserAlreadyExists(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Equal(t, user.ErrUserExists, store.AddUser("phil", "philhash", user.RoleUser, false)) - }) -} - -func TestStoreRemoveUser(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, "phil", u.Name) - - require.Nil(t, store.RemoveUser("phil")) - _, err = store.User("phil") - require.Equal(t, user.ErrUserNotFound, err) - }) -} - -func TestStoreUserByID(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleAdmin, false)) - u, err := store.User("phil") - require.Nil(t, err) - - u2, err := store.UserByID(u.ID) - require.Nil(t, err) - require.Equal(t, u.Name, u2.Name) - require.Equal(t, u.ID, u2.ID) - }) -} - -func TestStoreUserByToken(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - tk, err := store.CreateToken(u.ID, "tk_test123", "test token", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(24*time.Hour), 0, false) - require.Nil(t, err) - require.Equal(t, "tk_test123", tk.Value) - - u2, err := store.UserByToken(tk.Value) - require.Nil(t, err) - require.Equal(t, "phil", u2.Name) - }) -} - -func TestStoreUserByStripeCustomer(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.ChangeBilling("phil", &user.Billing{ - StripeCustomerID: "cus_test123", - StripeSubscriptionID: "sub_test123", - })) - - u, err := store.UserByStripeCustomer("cus_test123") - require.Nil(t, err) - require.Equal(t, "phil", u.Name) - require.Equal(t, "cus_test123", u.Billing.StripeCustomerID) - }) -} - -func TestStoreUsers(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AddUser("ben", "benhash", user.RoleAdmin, false)) - - users, err := store.Users() - require.Nil(t, err) - require.True(t, len(users) >= 3) // phil, ben, and the everyone user - }) -} - -func TestStoreUsersCount(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - count, err := store.UsersCount() - require.Nil(t, err) - require.True(t, count >= 1) // At least the everyone user - - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - count2, err := store.UsersCount() - require.Nil(t, err) - require.Equal(t, count+1, count2) - }) -} - -func TestStoreChangePassword(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, "philhash", u.Hash) - - require.Nil(t, store.ChangePassword("phil", "newhash")) - u, err = store.User("phil") - require.Nil(t, err) - require.Equal(t, "newhash", u.Hash) - }) -} - -func TestStoreChangeRole(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, user.RoleUser, u.Role) - - require.Nil(t, store.ChangeRole("phil", user.RoleAdmin)) - u, err = store.User("phil") - require.Nil(t, err) - require.Equal(t, user.RoleAdmin, u.Role) - }) -} - -func TestStoreTokens(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - now := time.Now() - expires := now.Add(24 * time.Hour) - origin := netip.MustParseAddr("9.9.9.9") - - tk, err := store.CreateToken(u.ID, "tk_abc", "my token", now, origin, expires, 0, false) - require.Nil(t, err) - require.Equal(t, "tk_abc", tk.Value) - require.Equal(t, "my token", tk.Label) - - // Get single token - tk2, err := store.Token(u.ID, "tk_abc") - require.Nil(t, err) - require.Equal(t, "tk_abc", tk2.Value) - require.Equal(t, "my token", tk2.Label) - - // Get all tokens - tokens, err := store.Tokens(u.ID) - require.Nil(t, err) - require.Len(t, tokens, 1) - require.Equal(t, "tk_abc", tokens[0].Value) - }) -} - -func TestStoreTokenChange(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - expires := time.Now().Add(time.Hour) - _, err = store.CreateToken(u.ID, "tk_abc", "old label", time.Now(), netip.MustParseAddr("1.2.3.4"), expires, 0, false) - require.Nil(t, err) - - newExpires := time.Now().Add(2 * time.Hour) - require.Nil(t, store.ChangeToken(u.ID, "tk_abc", "new label", newExpires)) - tk, err := store.Token(u.ID, "tk_abc") - require.Nil(t, err) - require.Equal(t, "new label", tk.Label) - require.Equal(t, newExpires.Unix(), tk.Expires.Unix()) - }) -} - -func TestStoreTokenRemove(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false) - require.Nil(t, err) - - require.Nil(t, store.RemoveToken(u.ID, "tk_abc")) - _, err = store.Token(u.ID, "tk_abc") - require.Equal(t, user.ErrTokenNotFound, err) - }) -} - -func TestStoreTokenRemoveExpired(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - // Create expired token and active token - _, err = store.CreateToken(u.ID, "tk_expired", "expired", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(-time.Hour), 0, false) - require.Nil(t, err) - _, err = store.CreateToken(u.ID, "tk_active", "active", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false) - require.Nil(t, err) - - require.Nil(t, store.RemoveExpiredTokens()) - - // Expired token should be gone - _, err = store.Token(u.ID, "tk_expired") - require.Equal(t, user.ErrTokenNotFound, err) - - // Active token should still exist - tk, err := store.Token(u.ID, "tk_active") - require.Nil(t, err) - require.Equal(t, "tk_active", tk.Value) - }) -} - -func TestStoreTokenCreatePrunesExcess(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - // Create 2 tokens with no pruning - for i, name := range []string{"tk_a", "tk_b"} { - _, err = store.CreateToken(u.ID, name, name, time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Duration(i+1)*time.Hour), 0, false) - require.Nil(t, err) - } - - // Create a 3rd token with maxTokenCount=2, which should prune tk_a (earliest expiry) - _, err = store.CreateToken(u.ID, "tk_c", "tk_c", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(3*time.Hour), 2, false) - require.Nil(t, err) - - tokens, err := store.Tokens(u.ID) - require.Nil(t, err) - require.Equal(t, 2, len(tokens)) - - // tk_a should be removed (earliest expiry) - _, err = store.Token(u.ID, "tk_a") - require.Equal(t, user.ErrTokenNotFound, err) - - // tk_b and tk_c should remain - _, err = store.Token(u.ID, "tk_b") - require.Nil(t, err) - _, err = store.Token(u.ID, "tk_c") - require.Nil(t, err) - }) -} - -func TestStoreTokenUpdateLastAccess(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - _, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false) - require.Nil(t, err) - - newTime := time.Now().Add(5 * time.Minute) - newOrigin := netip.MustParseAddr("5.5.5.5") - require.Nil(t, store.UpdateTokenLastAccess(map[string]*user.TokenUpdate{"tk_abc": {LastAccess: newTime, LastOrigin: newOrigin}})) - - tk, err := store.Token(u.ID, "tk_abc") - require.Nil(t, err) - require.Equal(t, newTime.Unix(), tk.LastAccess.Unix()) - require.Equal(t, newOrigin, tk.LastOrigin) - }) -} - -func TestStoreAllowAccess(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false)) - grants, err := store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 1) - require.Equal(t, "mytopic", grants[0].TopicPattern) - require.True(t, grants[0].Permission.IsReadWrite()) - }) -} - -func TestStoreAllowAccessReadOnly(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - - require.Nil(t, store.AllowAccess("phil", "announcements", true, false, "", false)) - grants, err := store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 1) - require.True(t, grants[0].Permission.IsRead()) - require.False(t, grants[0].Permission.IsWrite()) - }) -} - -func TestStoreResetAccess(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) - require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false)) - - grants, err := store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 2) - - require.Nil(t, store.ResetAccess("phil", "topic1")) - grants, err = store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 1) - require.Equal(t, "topic2", grants[0].TopicPattern) - }) -} - -func TestStoreResetAccessAll(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) - require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false)) - - require.Nil(t, store.ResetAccess("phil", "")) - grants, err := store.Grants("phil") - require.Nil(t, err) - require.Len(t, grants, 0) - }) -} - -func TestStoreAuthorizeTopicAccess(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false)) - - read, write, found, err := store.AuthorizeTopicAccess("phil", "mytopic") - require.Nil(t, err) - require.True(t, found) - require.True(t, read) - require.True(t, write) - }) -} - -func TestStoreAuthorizeTopicAccessNotFound(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - - _, _, found, err := store.AuthorizeTopicAccess("phil", "other") - require.Nil(t, err) - require.False(t, found) - }) -} - -func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "secret", false, false, "", false)) - - read, write, found, err := store.AuthorizeTopicAccess("phil", "secret") - require.Nil(t, err) - require.True(t, found) - require.False(t, read) - require.False(t, write) - }) -} - -func TestStoreReservations(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) - require.Nil(t, store.AllowAccess(user.Everyone, "mytopic", true, false, "phil", false)) - - reservations, err := store.Reservations("phil") - require.Nil(t, err) - require.Len(t, reservations, 1) - require.Equal(t, "mytopic", reservations[0].Topic) - require.True(t, reservations[0].Owner.IsReadWrite()) - require.True(t, reservations[0].Everyone.IsRead()) - require.False(t, reservations[0].Everyone.IsWrite()) - }) -} - -func TestStoreReservationsCount(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "phil", false)) - require.Nil(t, store.AllowAccess("phil", "topic2", true, true, "phil", false)) - - count, err := store.ReservationsCount("phil") - require.Nil(t, err) - require.Equal(t, int64(2), count) - }) -} - -func TestStoreHasReservation(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) - - has, err := store.HasReservation("phil", "mytopic") - require.Nil(t, err) - require.True(t, has) - - has, err = store.HasReservation("phil", "other") - require.Nil(t, err) - require.False(t, has) - }) -} - -func TestStoreReservationOwner(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false)) - - owner, err := store.ReservationOwner("mytopic") - require.Nil(t, err) - require.NotEmpty(t, owner) // Returns the user ID - - owner, err = store.ReservationOwner("unowned") - require.Nil(t, err) - require.Empty(t, owner) - }) -} - -func TestStoreTiers(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - MessageLimit: 5000, - MessageExpiryDuration: 24 * time.Hour, - EmailLimit: 100, - CallLimit: 10, - ReservationLimit: 20, - AttachmentFileSizeLimit: 10 * 1024 * 1024, - AttachmentTotalSizeLimit: 100 * 1024 * 1024, - AttachmentExpiryDuration: 48 * time.Hour, - AttachmentBandwidthLimit: 500 * 1024 * 1024, - } - require.Nil(t, store.AddTier(tier)) - - // Get by code - t2, err := store.Tier("pro") - require.Nil(t, err) - require.Equal(t, "ti_test", t2.ID) - require.Equal(t, "pro", t2.Code) - require.Equal(t, "Pro", t2.Name) - require.Equal(t, int64(5000), t2.MessageLimit) - require.Equal(t, int64(100), t2.EmailLimit) - require.Equal(t, int64(10), t2.CallLimit) - require.Equal(t, int64(20), t2.ReservationLimit) - - // List all tiers - tiers, err := store.Tiers() - require.Nil(t, err) - require.Len(t, tiers, 1) - require.Equal(t, "pro", tiers[0].Code) - }) -} - -func TestStoreTierUpdate(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - } - require.Nil(t, store.AddTier(tier)) - - tier.Name = "Professional" - tier.MessageLimit = 9999 - require.Nil(t, store.UpdateTier(tier)) - - t2, err := store.Tier("pro") - require.Nil(t, err) - require.Equal(t, "Professional", t2.Name) - require.Equal(t, int64(9999), t2.MessageLimit) - }) -} - -func TestStoreTierRemove(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - } - require.Nil(t, store.AddTier(tier)) - - t2, err := store.Tier("pro") - require.Nil(t, err) - require.Equal(t, "pro", t2.Code) - - require.Nil(t, store.RemoveTier("pro")) - _, err = store.Tier("pro") - require.Equal(t, user.ErrTierNotFound, err) - }) -} - -func TestStoreTierByStripePrice(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - StripeMonthlyPriceID: "price_monthly", - StripeYearlyPriceID: "price_yearly", - } - require.Nil(t, store.AddTier(tier)) - - t2, err := store.TierByStripePrice("price_monthly") - require.Nil(t, err) - require.Equal(t, "pro", t2.Code) - - t3, err := store.TierByStripePrice("price_yearly") - require.Nil(t, err) - require.Equal(t, "pro", t3.Code) - }) -} - -func TestStoreChangeTier(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - tier := &user.Tier{ - ID: "ti_test", - Code: "pro", - Name: "Pro", - } - require.Nil(t, store.AddTier(tier)) - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.ChangeTier("phil", "pro")) - - u, err := store.User("phil") - require.Nil(t, err) - require.NotNil(t, u.Tier) - require.Equal(t, "pro", u.Tier.Code) - }) -} - -func TestStorePhoneNumbers(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - require.Nil(t, store.AddPhoneNumber(u.ID, "+1234567890")) - require.Nil(t, store.AddPhoneNumber(u.ID, "+0987654321")) - - numbers, err := store.PhoneNumbers(u.ID) - require.Nil(t, err) - require.Len(t, numbers, 2) - - require.Nil(t, store.RemovePhoneNumber(u.ID, "+1234567890")) - numbers, err = store.PhoneNumbers(u.ID) - require.Nil(t, err) - require.Len(t, numbers, 1) - require.Equal(t, "+0987654321", numbers[0]) - }) -} - -func TestStoreChangeSettings(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - lang := "de" - prefs := &user.Prefs{Language: &lang} - require.Nil(t, store.ChangeSettings(u.ID, prefs)) - - u2, err := store.User("phil") - require.Nil(t, err) - require.NotNil(t, u2.Prefs) - require.Equal(t, "de", *u2.Prefs.Language) - }) -} - -func TestStoreChangeBilling(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - - billing := &user.Billing{ - StripeCustomerID: "cus_123", - StripeSubscriptionID: "sub_456", - } - require.Nil(t, store.ChangeBilling("phil", billing)) - - u, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, "cus_123", u.Billing.StripeCustomerID) - require.Equal(t, "sub_456", u.Billing.StripeSubscriptionID) - }) -} - -func TestStoreUpdateStats(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - stats := &user.Stats{Messages: 42, Emails: 3, Calls: 1} - require.Nil(t, store.UpdateStats(map[string]*user.Stats{u.ID: stats})) - - u2, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, int64(42), u2.Stats.Messages) - require.Equal(t, int64(3), u2.Stats.Emails) - require.Equal(t, int64(1), u2.Stats.Calls) - }) -} - -func TestStoreResetStats(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - require.Nil(t, store.UpdateStats(map[string]*user.Stats{u.ID: {Messages: 42, Emails: 3, Calls: 1}})) - require.Nil(t, store.ResetStats()) - - u2, err := store.User("phil") - require.Nil(t, err) - require.Equal(t, int64(0), u2.Stats.Messages) - require.Equal(t, int64(0), u2.Stats.Emails) - require.Equal(t, int64(0), u2.Stats.Calls) - }) -} - -func TestStoreMarkUserRemoved(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - require.Nil(t, store.MarkUserRemoved(u.ID, u.Name)) - - u2, err := store.User("phil") - require.Nil(t, err) - require.True(t, u2.Deleted) - }) -} - -func TestStoreRemoveDeletedUsers(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - u, err := store.User("phil") - require.Nil(t, err) - - require.Nil(t, store.MarkUserRemoved(u.ID, u.Name)) - - // RemoveDeletedUsers only removes users past the hard-delete duration (7 days). - // Immediately after marking, the user should still exist. - require.Nil(t, store.RemoveDeletedUsers()) - u2, err := store.User("phil") - require.Nil(t, err) - require.True(t, u2.Deleted) - }) -} - -func TestStoreAllGrants(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false)) - phil, err := store.User("phil") - require.Nil(t, err) - ben, err := store.User("ben") - require.Nil(t, err) - - require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false)) - require.Nil(t, store.AllowAccess("ben", "topic2", true, false, "", false)) - - grants, err := store.AllGrants() - require.Nil(t, err) - require.Contains(t, grants, phil.ID) - require.Contains(t, grants, ben.ID) - }) -} - -func TestStoreOtherAccessCount(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, store user.Store) { - require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false)) - require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false)) - require.Nil(t, store.AllowAccess("ben", "mytopic", true, true, "ben", false)) - - count, err := store.OtherAccessCount("phil", "mytopic") - require.Nil(t, err) - require.Equal(t, 1, count) - }) -} diff --git a/user/types.go b/user/types.go index be589643..f5d5e70e 100644 --- a/user/types.go +++ b/user/types.go @@ -1,12 +1,14 @@ package user import ( + "database/sql" "errors" - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/payments" "net/netip" "strings" "time" + + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/payments" ) // User is a struct that represents a user @@ -273,3 +275,78 @@ var ( ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user") ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") ) + +// storeQueries holds the database-specific SQL queries +type storeQueries struct { + // User queries + selectUserByID string + selectUserByName string + selectUserByToken string + selectUserByStripeCustomerID string + selectUsernames string + selectUserCount string + selectUserIDFromUsername string + insertUser string + updateUserPass string + updateUserRole string + updateUserProvisioned string + updateUserPrefs string + updateUserStats string + updateUserStatsResetAll string + updateUserTier string + updateUserDeleted string + deleteUser string + deleteUserTier string + deleteUsersMarked string + + // Access queries + selectTopicPerms string + selectUserAllAccess string + selectUserAccess string + selectUserReservations string + selectUserReservationsCount string + selectUserReservationsOwner string + selectUserHasReservation string + selectOtherAccessCount string + upsertUserAccess string + deleteUserAccess string + deleteUserAccessProvisioned string + deleteTopicAccess string + deleteAllAccess string + + // Token queries + selectToken string + selectTokens string + selectTokenCount string + selectAllProvisionedTokens string + upsertToken string + updateToken string + updateTokenLastAccess string + deleteToken string + deleteProvisionedToken string + deleteAllToken string + deleteExpiredTokens string + deleteExcessTokens string + + // Tier queries + insertTier string + selectTiers string + selectTierByCode string + selectTierByPriceID string + updateTier string + deleteTier string + + // Phone queries + selectPhoneNumbers string + insertPhoneNumber string + deletePhoneNumber string + + // Billing queries + updateBilling string +} + +// execer is satisfied by both *sql.DB and *sql.Tx, allowing helper methods +// to be used both standalone and within a transaction. +type execer interface { + Exec(query string, args ...any) (sql.Result, error) +} From 542aa403d2172d9be36267be04cbc8cf5360dadb Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 28 Feb 2026 19:34:09 -0500 Subject: [PATCH 245/548] Re-order functions --- user/manager.go | 1482 +++++++++++++++++++++++------------------------ 1 file changed, 722 insertions(+), 760 deletions(-) diff --git a/user/manager.go b/user/manager.go index d18bbc8f..d20836e6 100644 --- a/user/manager.go +++ b/user/manager.go @@ -112,52 +112,234 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { return user, nil } -// CreateToken generates a random token for the given user and returns it. The token expires -// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the -// given user, if there are too many of them. -func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) { - return a.createToken(userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned) +// AddUser adds a user with the given username, password and role +func (a *Manager) AddUser(username, password string, role Role, hashed bool) error { + return a.addUser(username, password, role, hashed, false) } -// ChangeToken updates a token's label and/or expiry date -func (a *Manager) ChangeToken(userID, token string, label *string, expires *time.Time) (*Token, error) { - if token == "" { - return nil, errNoTokenProvided +func (a *Manager) addUser(username, password string, role Role, hashed, provisioned bool) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument } - if err := a.canChangeToken(userID, token); err != nil { - return nil, err + var hash string + var err error + if hashed { + hash = password + if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { + return err + } + } else { + hash, err = hashPassword(password, a.config.BcryptCost) + if err != nil { + return err + } } - t, err := a.Token(userID, token) - if err != nil { - return nil, err - } - if label != nil { - t.Label = *label - } - if expires != nil { - t.Expires = *expires - } - if err := a.changeToken(userID, token, t.Label, t.Expires); err != nil { - return nil, err - } - return t, nil + return a.insertUser(username, hash, role, provisioned) } -// RemoveToken deletes the token defined in User.Token -func (a *Manager) RemoveToken(userID, token string) error { - if err := a.canChangeToken(userID, token); err != nil { +// insertUser adds a user with the given username, password hash and role to the database +func (a *Manager) insertUser(username, hash string, role Role, provisioned bool) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + userID := util.RandomStringPrefix(userIDPrefix, userIDLength) + syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) + now := time.Now().Unix() + if _, err := a.db.Exec(a.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil { + if isUniqueConstraintError(err) { + return ErrUserExists + } return err } - return a.removeToken(userID, token) + return nil } -// canChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed. -func (a *Manager) canChangeToken(userID, token string) error { - t, err := a.Token(userID, token) +// RemoveUser deletes the user with the given username. The function returns nil on success, even +// if the user did not exist in the first place. +func (a *Manager) RemoveUser(username string) error { + if err := a.CanChangeUser(username); err != nil { + return err + } + return a.removeUser(username) +} + +// removeUser deletes the user with the given username +func (a *Manager) removeUser(username string) error { + if !AllowedUsername(username) { + return ErrInvalidArgument + } + // Rows in user_access, user_token, etc. are deleted via foreign keys + if _, err := a.db.Exec(a.queries.deleteUser, username); err != nil { + return err + } + return nil +} + +// MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens. This prevents +// successful auth via Authenticate. A background process will delete the user at a later date. +func (a *Manager) MarkUserRemoved(user *User) error { + if !AllowedUsername(user.Name) { + return ErrInvalidArgument + } + tx, err := a.db.Begin() if err != nil { return err - } else if t.Provisioned { - return ErrProvisionedTokenChange + } + defer tx.Rollback() + if err := a.resetUserAccessTx(tx, user.Name); err != nil { + return err + } + if _, err := tx.Exec(a.queries.deleteAllToken, user.ID); err != nil { + return err + } + deletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix() + if _, err := tx.Exec(a.queries.updateUserDeleted, deletedTime, user.ID); err != nil { + return err + } + return tx.Commit() +} + +// RemoveDeletedUsers deletes all users that have been marked deleted +func (a *Manager) RemoveDeletedUsers() error { + if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil { + return err + } + return nil +} + +// ChangePassword changes a user's password +func (a *Manager) ChangePassword(username, password string, hashed bool) error { + if err := a.CanChangeUser(username); err != nil { + return err + } + var hash string + var err error + if hashed { + hash = password + if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { + return err + } + } else { + hash, err = hashPassword(password, a.config.BcryptCost) + if err != nil { + return err + } + } + return a.changePasswordHash(username, hash) +} + +// changePasswordHash changes a user's password hash in the database +func (a *Manager) changePasswordHash(username, hash string) error { + if _, err := a.db.Exec(a.queries.updateUserPass, hash, username); err != nil { + return err + } + return nil +} + +// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, +// all existing access control entries (Grant) are removed, since they are no longer needed. +func (a *Manager) ChangeRole(username string, role Role) error { + if err := a.CanChangeUser(username); err != nil { + return err + } + return a.changeRole(username, role) +} + +// changeRole changes a user's role +func (a *Manager) changeRole(username string, role Role) error { + if !AllowedUsername(username) || !AllowedRole(role) { + return ErrInvalidArgument + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(a.queries.updateUserRole, string(role), username); err != nil { + return err + } + // If changing to admin, remove all access entries + if role == RoleAdmin { + if err := a.resetUserAccessTx(tx, username); err != nil { + return err + } + } + return tx.Commit() +} + +// CanChangeUser checks if the user with the given username can be changed. +// This is used to prevent changes to provisioned users, which are defined in the config file. +func (a *Manager) CanChangeUser(username string) error { + user, err := a.User(username) + if err != nil { + return err + } else if user.Provisioned { + return ErrProvisionedUserChange + } + return nil +} + +// ChangeProvisioned changes the provisioned status of a user +func (a *Manager) ChangeProvisioned(username string, provisioned bool) error { + if _, err := a.db.Exec(a.queries.updateUserProvisioned, provisioned, username); err != nil { + return err + } + return nil +} + +// ChangeSettings persists the user settings +func (a *Manager) ChangeSettings(userID string, prefs *Prefs) error { + b, err := json.Marshal(prefs) + if err != nil { + return err + } + if _, err := a.db.Exec(a.queries.updateUserPrefs, string(b), userID); err != nil { + return err + } + return nil +} + +// ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, +// or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere. +func (a *Manager) ChangeTier(username, tier string) error { + if !AllowedUsername(username) { + return ErrInvalidArgument + } + t, err := a.Tier(tier) + if err != nil { + return err + } else if err := a.checkReservationsLimit(username, t.ReservationLimit); err != nil { + return err + } + if _, err := a.db.Exec(a.queries.updateUserTier, tier, username); err != nil { + return err + } + return nil +} + +// ResetTier removes the tier from the given user +func (a *Manager) ResetTier(username string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } else if err := a.checkReservationsLimit(username, 0); err != nil { + return err + } + _, err := a.db.Exec(a.queries.deleteUserTier, username) + return err +} + +func (a *Manager) checkReservationsLimit(username string, reservationsLimit int64) error { + u, err := a.User(username) + if err != nil { + return err + } + if u.Tier != nil && reservationsLimit < u.Tier.ReservationLimit { + reservations, err := a.Reservations(username) + if err != nil { + return err + } else if int64(len(reservations)) > reservationsLimit { + return ErrTooManyReservations + } } return nil } @@ -166,19 +348,26 @@ func (a *Manager) canChangeToken(userID, token string) error { func (a *Manager) ResetStats() error { a.mu.Lock() // Includes database query to avoid races! defer a.mu.Unlock() - if err := a.resetStats(); err != nil { + if _, err := a.db.Exec(a.queries.updateUserStatsResetAll); err != nil { return err } a.statsQueue = make(map[string]*Stats) return nil } -// resetStats resets all user stats in the user database -func (a *Manager) resetStats() error { - if _, err := a.db.Exec(a.queries.updateUserStatsResetAll); err != nil { +// UpdateStats updates statistics for one or more users in a single transaction +func (a *Manager) UpdateStats(stats map[string]*Stats) error { + tx, err := a.db.Begin() + if err != nil { return err } - return nil + defer tx.Rollback() + for userID, update := range stats { + if _, err := tx.Exec(a.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { + return err + } + } + return tx.Commit() } // EnqueueUserStats adds the user to a queue which writes out user stats (messages, emails, ..) in @@ -189,14 +378,6 @@ func (a *Manager) EnqueueUserStats(userID string, stats *Stats) { a.statsQueue[userID] = stats } -// EnqueueTokenUpdate adds the token update to a queue which writes out token access times -// in batches at a regular interval -func (a *Manager) EnqueueTokenUpdate(tokenID string, update *TokenUpdate) { - a.mu.Lock() - defer a.mu.Unlock() - a.tokenQueue[tokenID] = update -} - func (a *Manager) asyncQueueWriter(interval time.Duration) { ticker := time.NewTicker(interval) for range ticker.C { @@ -235,381 +416,6 @@ func (a *Manager) writeUserStatsQueue() error { return a.UpdateStats(statsQueue) } -func (a *Manager) writeTokenUpdateQueue() error { - a.mu.Lock() - if len(a.tokenQueue) == 0 { - a.mu.Unlock() - log.Tag(tag).Trace("No token updates to commit") - return nil - } - tokenQueue := a.tokenQueue - a.tokenQueue = make(map[string]*TokenUpdate) - a.mu.Unlock() - - log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) - for tokenID, update := range tokenQueue { - log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) - } - return a.UpdateTokenLastAccess(tokenQueue) -} - -// Authorize returns nil if the given user has access to the given topic using the desired -// permission. The user param may be nil to signal an anonymous user. -func (a *Manager) Authorize(user *User, topic string, perm Permission) error { - if user != nil && user.Role == RoleAdmin { - return nil // Admin can do everything - } - username := Everyone - if user != nil { - username = user.Name - } - // Select the read/write permissions for this user/topic combo. - read, write, found, err := a.AuthorizeTopicAccess(username, topic) - if err != nil { - return err - } - if !found { - return a.resolvePerms(a.config.DefaultAccess, perm) - } - return a.resolvePerms(NewPermission(read, write), perm) -} - -func (a *Manager) resolvePerms(base, perm Permission) error { - if perm == PermissionRead && base.IsRead() { - return nil - } else if perm == PermissionWrite && base.IsWrite() { - return nil - } - return ErrUnauthorized -} - -// AddUser adds a user with the given username, password and role -func (a *Manager) AddUser(username, password string, role Role, hashed bool) error { - return a.addUser(username, password, role, hashed, false) -} - -func (a *Manager) addUser(username, password string, role Role, hashed, provisioned bool) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - var hash string - var err error - if hashed { - hash = password - if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { - return err - } - } else { - hash, err = hashPassword(password, a.config.BcryptCost) - if err != nil { - return err - } - } - return a.insertUser(username, hash, role, provisioned) -} - -// RemoveUser deletes the user with the given username. The function returns nil on success, even -// if the user did not exist in the first place. -func (a *Manager) RemoveUser(username string) error { - if err := a.CanChangeUser(username); err != nil { - return err - } - return a.removeUser(username) -} - -// MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens. This prevents -// successful auth via Authenticate. A background process will delete the user at a later date. -func (a *Manager) MarkUserRemoved(user *User) error { - if !AllowedUsername(user.Name) { - return ErrInvalidArgument - } - return a.markUserRemoved(user.ID, user.Name) -} - -// ChangePassword changes a user's password -func (a *Manager) ChangePassword(username, password string, hashed bool) error { - if err := a.CanChangeUser(username); err != nil { - return err - } - var hash string - var err error - if hashed { - hash = password - if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { - return err - } - } else { - hash, err = hashPassword(password, a.config.BcryptCost) - if err != nil { - return err - } - } - return a.changePasswordHash(username, hash) -} - -// CanChangeUser checks if the user with the given username can be changed. -// This is used to prevent changes to provisioned users, which are defined in the config file. -func (a *Manager) CanChangeUser(username string) error { - user, err := a.User(username) - if err != nil { - return err - } else if user.Provisioned { - return ErrProvisionedUserChange - } - return nil -} - -// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, -// all existing access control entries (Grant) are removed, since they are no longer needed. -func (a *Manager) ChangeRole(username string, role Role) error { - if err := a.CanChangeUser(username); err != nil { - return err - } - return a.changeRole(username, role) -} - -// ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, -// or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere. -func (a *Manager) ChangeTier(username, tier string) error { - if !AllowedUsername(username) { - return ErrInvalidArgument - } - t, err := a.Tier(tier) - if err != nil { - return err - } else if err := a.checkReservationsLimit(username, t.ReservationLimit); err != nil { - return err - } - return a.changeTierCode(username, tier) -} - -// ResetTier removes the tier from the given user -func (a *Manager) ResetTier(username string) error { - if !AllowedUsername(username) && username != Everyone && username != "" { - return ErrInvalidArgument - } else if err := a.checkReservationsLimit(username, 0); err != nil { - return err - } - return a.resetTierCode(username) -} - -func (a *Manager) checkReservationsLimit(username string, reservationsLimit int64) error { - u, err := a.User(username) - if err != nil { - return err - } - if u.Tier != nil && reservationsLimit < u.Tier.ReservationLimit { - reservations, err := a.Reservations(username) - if err != nil { - return err - } else if int64(len(reservations)) > reservationsLimit { - return ErrTooManyReservations - } - } - return nil -} - -// AllowReservation tests if a user may create an access control entry for the given topic. -// If there are any ACL entries that are not owned by the user, an error is returned. -func (a *Manager) AllowReservation(username string, topic string) error { - if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { - return ErrInvalidArgument - } - otherCount, err := a.OtherAccessCount(username, topic) - if err != nil { - return err - } - if otherCount > 0 { - return errTopicOwnedByOthers - } - return nil -} - -// AllowAccess adds or updates an entry in the access control list for a specific user. It controls -// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry -// owner may either be a user (username), or the system (empty). -func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { - return a.allowAccess(username, topicPattern, permission, false) -} - -func (a *Manager) allowAccess(username string, topicPattern string, permission Permission, provisioned bool) error { - if !AllowedUsername(username) && username != Everyone { - return ErrInvalidArgument - } else if !AllowedTopicPattern(topicPattern) { - return ErrInvalidArgument - } - return a.allowAccessTx(a.db, username, topicPattern, permission.IsRead(), permission.IsWrite(), "", provisioned) -} - -// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is -// empty) for an entire user. The parameter topicPattern may include wildcards (*). -func (a *Manager) ResetAccess(username string, topicPattern string) error { - return a.resetAccess(username, topicPattern) -} - -func (a *Manager) resetAccess(username string, topicPattern string) error { - if !AllowedUsername(username) && username != Everyone && username != "" { - return ErrInvalidArgument - } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { - return ErrInvalidArgument - } - if username == "" && topicPattern == "" { - _, err := a.db.Exec(a.queries.deleteAllAccess) - return err - } else if topicPattern == "" { - return a.resetUserAccessTx(a.db, username) - } - return a.resetTopicAccessTx(a.db, username, topicPattern) -} - -// DefaultAccess returns the default read/write access if no access control entry matches -func (a *Manager) DefaultAccess() Permission { - return a.config.DefaultAccess -} - -// Close closes the underlying database -func (a *Manager) Close() error { - return a.db.Close() -} - -// maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config. -func (a *Manager) maybeProvisionUsersAccessAndTokens() error { - if !a.config.ProvisionEnabled { - return nil - } - existingUsers, err := a.Users() - if err != nil { - return err - } - provisionUsernames := util.Map(a.config.Users, func(u *User) string { - return u.Name - }) - if err := a.maybeProvisionUsers(provisionUsernames, existingUsers); err != nil { - return fmt.Errorf("failed to provision users: %v", err) - } - if err := a.maybeProvisionGrants(); err != nil { - return fmt.Errorf("failed to provision grants: %v", err) - } - if err := a.maybeProvisionTokens(provisionUsernames); err != nil { - return fmt.Errorf("failed to provision tokens: %v", err) - } - return nil -} - -// maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them. -// It also removes users that are provisioned, but not in the config anymore. -func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers []*User) error { - // Remove users that are provisioned, but not in the config anymore - for _, user := range existingUsers { - if user.Name == Everyone { - continue - } else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) { - if err := a.removeUser(user.Name); err != nil { - return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err) - } - } - } - // Add or update provisioned users - for _, user := range a.config.Users { - if user.Name == Everyone { - continue - } - existingUser, exists := util.Find(existingUsers, func(u *User) bool { - return u.Name == user.Name - }) - if !exists { - if err := a.addUser(user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { - return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) - } - } else { - if !existingUser.Provisioned { - if err := a.ChangeProvisioned(user.Name, true); err != nil { - return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err) - } - } - if existingUser.Hash != user.Hash { - if err := a.changePasswordHash(user.Name, user.Hash); err != nil { - return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) - } - } - if existingUser.Role != user.Role { - if err := a.changeRole(user.Name, user.Role); err != nil { - return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) - } - } - } - } - return nil -} - -// maybeProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config. -// -// Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last -// access time) or do not have dependent resources (such as grants or tokens). -func (a *Manager) maybeProvisionGrants() error { - // Remove all provisioned grants - if err := a.ResetAllProvisionedAccess(); err != nil { - return err - } - // (Re-)add provisioned grants - for username, grants := range a.config.Access { - user, exists := util.Find(a.config.Users, func(u *User) bool { - return u.Name == username - }) - if !exists && username != Everyone { - return fmt.Errorf("user %s is not a provisioned user, refusing to add ACL entry", username) - } else if user != nil && user.Role == RoleAdmin { - return fmt.Errorf("adding access control entries is not allowed for admin roles for user %s", username) - } - for _, grant := range grants { - if err := a.resetAccess(username, grant.TopicPattern); err != nil { - return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err) - } - if err := a.allowAccess(username, grant.TopicPattern, grant.Permission, true); err != nil { - return err - } - } - } - return nil -} - -func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { - // Remove tokens that are provisioned, but not in the config anymore - existingTokens, err := a.AllProvisionedTokens() - if err != nil { - return fmt.Errorf("failed to retrieve existing provisioned tokens: %v", err) - } - var provisionTokens []string - for _, userTokens := range a.config.Tokens { - for _, token := range userTokens { - provisionTokens = append(provisionTokens, token.Value) - } - } - for _, existingToken := range existingTokens { - if !slices.Contains(provisionTokens, existingToken.Value) { - if err := a.RemoveProvisionedToken(existingToken.Value); err != nil { - return fmt.Errorf("failed to remove provisioned token %s: %v", existingToken.Value, err) - } - } - } - // (Re-)add provisioned tokens - for username, tokens := range a.config.Tokens { - if !slices.Contains(provisionUsernames, username) && username != Everyone { - return fmt.Errorf("user %s is not a provisioned user, refusing to add tokens", username) - } - userID, err := a.UserIDByUsername(username) - if err != nil { - return fmt.Errorf("failed to find provisioned user %s for provisioned tokens: %v", username, err) - } - for _, token := range tokens { - if _, err := a.createToken(userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), 0, true); err != nil { - return err - } - } - } - return nil -} - // User returns the user with the given username if it exists, or ErrUserNotFound otherwise func (a *Manager) User(username string) (*User, error) { rows, err := a.db.Query(a.queries.selectUserByName, username) @@ -692,143 +498,21 @@ func (a *Manager) UsersCount() (int64, error) { return count, nil } -// insertUser adds a user with the given username, password hash and role to the database -func (a *Manager) insertUser(username, hash string, role Role, provisioned bool) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - userID := util.RandomStringPrefix(userIDPrefix, userIDLength) - syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) - now := time.Now().Unix() - if _, err := a.db.Exec(a.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil { - if isUniqueConstraintError(err) { - return ErrUserExists - } - return err - } - return nil -} - -// removeUser deletes the user with the given username -func (a *Manager) removeUser(username string) error { - if !AllowedUsername(username) { - return ErrInvalidArgument - } - // Rows in user_access, user_token, etc. are deleted via foreign keys - if _, err := a.db.Exec(a.queries.deleteUser, username); err != nil { - return err - } - return nil -} - -// markUserRemoved sets the deleted flag on the user, and deletes all access tokens -func (a *Manager) markUserRemoved(userID, username string) error { - tx, err := a.db.Begin() +// UserIDByUsername returns the user ID for the given username +func (a *Manager) UserIDByUsername(username string) (string, error) { + rows, err := a.db.Query(a.queries.selectUserIDFromUsername, username) if err != nil { - return err + return "", err } - defer tx.Rollback() - if err := a.resetUserAccessTx(tx, username); err != nil { - return err + defer rows.Close() + if !rows.Next() { + return "", ErrUserNotFound } - if _, err := tx.Exec(a.queries.deleteAllToken, userID); err != nil { - return err + var userID string + if err := rows.Scan(&userID); err != nil { + return "", err } - deletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix() - if _, err := tx.Exec(a.queries.updateUserDeleted, deletedTime, userID); err != nil { - return err - } - return tx.Commit() -} - -// RemoveDeletedUsers deletes all users that have been marked deleted -func (a *Manager) RemoveDeletedUsers() error { - if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil { - return err - } - return nil -} - -// changePasswordHash changes a user's password hash in the database -func (a *Manager) changePasswordHash(username, hash string) error { - if _, err := a.db.Exec(a.queries.updateUserPass, hash, username); err != nil { - return err - } - return nil -} - -// changeRole changes a user's role -func (a *Manager) changeRole(username string, role Role) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(a.queries.updateUserRole, string(role), username); err != nil { - return err - } - // If changing to admin, remove all access entries - if role == RoleAdmin { - if err := a.resetUserAccessTx(tx, username); err != nil { - return err - } - } - return tx.Commit() -} - -// ChangeProvisioned changes the provisioned status of a user -func (a *Manager) ChangeProvisioned(username string, provisioned bool) error { - if _, err := a.db.Exec(a.queries.updateUserProvisioned, provisioned, username); err != nil { - return err - } - return nil -} - -// ChangeSettings persists the user settings -func (a *Manager) ChangeSettings(userID string, prefs *Prefs) error { - b, err := json.Marshal(prefs) - if err != nil { - return err - } - if _, err := a.db.Exec(a.queries.updateUserPrefs, string(b), userID); err != nil { - return err - } - return nil -} - -// changeTierCode changes a user's tier using the tier code in the database -func (a *Manager) changeTierCode(username, tierCode string) error { - if _, err := a.db.Exec(a.queries.updateUserTier, tierCode, username); err != nil { - return err - } - return nil -} - -// resetTierCode removes the tier from the given user in the database -func (a *Manager) resetTierCode(username string) error { - if !AllowedUsername(username) && username != Everyone && username != "" { - return ErrInvalidArgument - } - _, err := a.db.Exec(a.queries.deleteUserTier, username) - return err -} - -// UpdateStats updates statistics for one or more users in a single transaction -func (a *Manager) UpdateStats(stats map[string]*Stats) error { - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for userID, update := range stats { - if _, err := tx.Exec(a.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { - return err - } - } - return tx.Commit() + return userID, nil } func (a *Manager) readUser(rows *sql.Rows) (*User, error) { @@ -894,170 +578,94 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { return user, nil } -// createToken creates a new token and prunes excess tokens if the count exceeds maxTokenCount. -// If maxTokenCount is 0, no pruning is performed. -func (a *Manager) createToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) { - tx, err := a.db.Begin() +// Authorize returns nil if the given user has access to the given topic using the desired +// permission. The user param may be nil to signal an anonymous user. +func (a *Manager) Authorize(user *User, topic string, perm Permission) error { + if user != nil && user.Role == RoleAdmin { + return nil // Admin can do everything + } + username := Everyone + if user != nil { + username = user.Name + } + // Select the read/write permissions for this user/topic combo. + read, write, found, err := a.AuthorizeTopicAccess(username, topic) if err != nil { - return nil, err - } - defer tx.Rollback() - if _, err := tx.Exec(a.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil { - return nil, err - } - if maxTokenCount > 0 { - var tokenCount int - if err := tx.QueryRow(a.queries.selectTokenCount, userID).Scan(&tokenCount); err != nil { - return nil, err - } - if tokenCount > maxTokenCount { - if _, err := tx.Exec(a.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil { - return nil, err - } - } - } - if err := tx.Commit(); err != nil { - return nil, err - } - return &Token{ - Value: token, - Label: label, - LastAccess: lastAccess, - LastOrigin: lastOrigin, - Expires: expires, - Provisioned: provisioned, - }, nil -} - -// Token returns a specific token for a user -func (a *Manager) Token(userID, token string) (*Token, error) { - rows, err := a.db.Query(a.queries.selectToken, userID, token) - if err != nil { - return nil, err - } - defer rows.Close() - return a.readToken(rows) -} - -// Tokens returns all existing tokens for the user with the given user ID -func (a *Manager) Tokens(userID string) ([]*Token, error) { - rows, err := a.db.Query(a.queries.selectTokens, userID) - if err != nil { - return nil, err - } - defer rows.Close() - tokens := make([]*Token, 0) - for { - token, err := a.readToken(rows) - if errors.Is(err, ErrTokenNotFound) { - break - } else if err != nil { - return nil, err - } - tokens = append(tokens, token) - } - return tokens, nil -} - -// AllProvisionedTokens returns all provisioned tokens -func (a *Manager) AllProvisionedTokens() ([]*Token, error) { - rows, err := a.db.Query(a.queries.selectAllProvisionedTokens) - if err != nil { - return nil, err - } - defer rows.Close() - tokens := make([]*Token, 0) - for { - token, err := a.readToken(rows) - if errors.Is(err, ErrTokenNotFound) { - break - } else if err != nil { - return nil, err - } - tokens = append(tokens, token) - } - return tokens, nil -} - -// changeToken updates a token's label and expiry time -func (a *Manager) changeToken(userID, token, label string, expires time.Time) error { - if _, err := a.db.Exec(a.queries.updateToken, label, expires.Unix(), userID, token); err != nil { return err } + if !found { + return a.resolvePerms(a.config.DefaultAccess, perm) + } + return a.resolvePerms(NewPermission(read, write), perm) +} + +func (a *Manager) resolvePerms(base, perm Permission) error { + if perm == PermissionRead && base.IsRead() { + return nil + } else if perm == PermissionWrite && base.IsWrite() { + return nil + } + return ErrUnauthorized +} + +// AllowAccess adds or updates an entry in the access control list for a specific user. It controls +// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry +// owner may either be a user (username), or the system (empty). +func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { + return a.allowAccess(username, topicPattern, permission, false) +} + +func (a *Manager) allowAccess(username string, topicPattern string, permission Permission, provisioned bool) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) { + return ErrInvalidArgument + } + return a.allowAccessTx(a.db, username, topicPattern, permission.IsRead(), permission.IsWrite(), "", provisioned) +} + +// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is +// empty) for an entire user. The parameter topicPattern may include wildcards (*). +func (a *Manager) ResetAccess(username string, topicPattern string) error { + return a.resetAccess(username, topicPattern) +} + +func (a *Manager) resetAccess(username string, topicPattern string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { + return ErrInvalidArgument + } + if username == "" && topicPattern == "" { + _, err := a.db.Exec(a.queries.deleteAllAccess) + return err + } else if topicPattern == "" { + return a.resetUserAccessTx(a.db, username) + } + return a.resetTopicAccessTx(a.db, username, topicPattern) +} + +// DefaultAccess returns the default read/write access if no access control entry matches +func (a *Manager) DefaultAccess() Permission { + return a.config.DefaultAccess +} + +// AllowReservation tests if a user may create an access control entry for the given topic. +// If there are any ACL entries that are not owned by the user, an error is returned. +func (a *Manager) AllowReservation(username string, topic string) error { + if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { + return ErrInvalidArgument + } + otherCount, err := a.OtherAccessCount(username, topic) + if err != nil { + return err + } + if otherCount > 0 { + return errTopicOwnedByOthers + } return nil } -// UpdateTokenLastAccess updates the last access time and origin for one or more tokens in a single transaction -func (a *Manager) UpdateTokenLastAccess(updates map[string]*TokenUpdate) error { - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for token, update := range updates { - if _, err := tx.Exec(a.queries.updateTokenLastAccess, update.LastAccess.Unix(), update.LastOrigin.String(), token); err != nil { - return err - } - } - return tx.Commit() -} - -// removeToken deletes the token -func (a *Manager) removeToken(userID, token string) error { - if token == "" { - return errNoTokenProvided - } - if _, err := a.db.Exec(a.queries.deleteToken, userID, token); err != nil { - return err - } - return nil -} - -// RemoveProvisionedToken deletes a provisioned token by value, regardless of user -func (a *Manager) RemoveProvisionedToken(token string) error { - if token == "" { - return errNoTokenProvided - } - if _, err := a.db.Exec(a.queries.deleteProvisionedToken, token); err != nil { - return err - } - return nil -} - -// RemoveExpiredTokens deletes all expired tokens from the database -func (a *Manager) RemoveExpiredTokens() error { - if _, err := a.db.Exec(a.queries.deleteExpiredTokens, time.Now().Unix()); err != nil { - return err - } - return nil -} - -func (a *Manager) readToken(rows *sql.Rows) (*Token, error) { - var token, label, lastOrigin string - var lastAccess, expires int64 - var provisioned bool - if !rows.Next() { - return nil, ErrTokenNotFound - } - if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - lastOriginIP, err := netip.ParseAddr(lastOrigin) - if err != nil { - lastOriginIP = netip.IPv4Unspecified() - } - return &Token{ - Value: token, - Label: label, - LastAccess: time.Unix(lastAccess, 0), - LastOrigin: lastOriginIP, - Expires: time.Unix(expires, 0), - Provisioned: provisioned, - }, nil -} - // AuthorizeTopicAccess returns the read/write permissions for the given username and topic. // The found return value indicates whether an ACL entry was found at all. // @@ -1134,42 +742,6 @@ func (a *Manager) Grants(username string) ([]Grant, error) { return grants, nil } -func (a *Manager) allowAccessTx(tx execer, username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { - if !AllowedUsername(username) && username != Everyone { - return ErrInvalidArgument - } else if !AllowedTopicPattern(topicPattern) { - return ErrInvalidArgument - } - _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned) - return err -} - -func (a *Manager) resetUserAccessTx(tx execer, username string) error { - if !AllowedUsername(username) && username != Everyone { - return ErrInvalidArgument - } - _, err := tx.Exec(a.queries.deleteUserAccess, username, username) - return err -} - -func (a *Manager) resetTopicAccessTx(tx execer, username, topicPattern string) error { - if !AllowedUsername(username) && username != Everyone && username != "" { - return ErrInvalidArgument - } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { - return ErrInvalidArgument - } - _, err := tx.Exec(a.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern)) - return err -} - -// ResetAllProvisionedAccess removes all provisioned access control entries -func (a *Manager) ResetAllProvisionedAccess() error { - if _, err := a.db.Exec(a.queries.deleteUserAccessProvisioned); err != nil { - return err - } - return nil -} - // AddReservation creates two access control entries for the given topic: one with full read/write // access for the given user, and one for Everyone with the given permission. Both entries are // created atomically in a single transaction. @@ -1313,6 +885,269 @@ func (a *Manager) OtherAccessCount(username, topic string) (int, error) { return count, nil } +// ResetAllProvisionedAccess removes all provisioned access control entries +func (a *Manager) ResetAllProvisionedAccess() error { + if _, err := a.db.Exec(a.queries.deleteUserAccessProvisioned); err != nil { + return err + } + return nil +} + +func (a *Manager) allowAccessTx(tx execer, username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) { + return ErrInvalidArgument + } + _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned) + return err +} + +func (a *Manager) resetUserAccessTx(tx execer, username string) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } + _, err := tx.Exec(a.queries.deleteUserAccess, username, username) + return err +} + +func (a *Manager) resetTopicAccessTx(tx execer, username, topicPattern string) error { + if !AllowedUsername(username) && username != Everyone && username != "" { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { + return ErrInvalidArgument + } + _, err := tx.Exec(a.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern)) + return err +} + +// CreateToken generates a random token for the given user and returns it. The token expires +// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the +// given user, if there are too many of them. +func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) { + return a.createToken(userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned) +} + +// createToken creates a new token and prunes excess tokens if the count exceeds maxTokenCount. +// If maxTokenCount is 0, no pruning is performed. +func (a *Manager) createToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) { + tx, err := a.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err := tx.Exec(a.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil { + return nil, err + } + if maxTokenCount > 0 { + var tokenCount int + if err := tx.QueryRow(a.queries.selectTokenCount, userID).Scan(&tokenCount); err != nil { + return nil, err + } + if tokenCount > maxTokenCount { + if _, err := tx.Exec(a.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil { + return nil, err + } + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &Token{ + Value: token, + Label: label, + LastAccess: lastAccess, + LastOrigin: lastOrigin, + Expires: expires, + Provisioned: provisioned, + }, nil +} + +// ChangeToken updates a token's label and/or expiry date +func (a *Manager) ChangeToken(userID, token string, label *string, expires *time.Time) (*Token, error) { + if token == "" { + return nil, errNoTokenProvided + } + if err := a.canChangeToken(userID, token); err != nil { + return nil, err + } + t, err := a.Token(userID, token) + if err != nil { + return nil, err + } + if label != nil { + t.Label = *label + } + if expires != nil { + t.Expires = *expires + } + if _, err := a.db.Exec(a.queries.updateToken, t.Label, t.Expires.Unix(), userID, token); err != nil { + return nil, err + } + return t, nil +} + +// RemoveToken deletes the token defined in User.Token +func (a *Manager) RemoveToken(userID, token string) error { + if err := a.canChangeToken(userID, token); err != nil { + return err + } + if token == "" { + return errNoTokenProvided + } + if _, err := a.db.Exec(a.queries.deleteToken, userID, token); err != nil { + return err + } + return nil +} + +// canChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed. +func (a *Manager) canChangeToken(userID, token string) error { + t, err := a.Token(userID, token) + if err != nil { + return err + } else if t.Provisioned { + return ErrProvisionedTokenChange + } + return nil +} + +// Token returns a specific token for a user +func (a *Manager) Token(userID, token string) (*Token, error) { + rows, err := a.db.Query(a.queries.selectToken, userID, token) + if err != nil { + return nil, err + } + defer rows.Close() + return a.readToken(rows) +} + +// Tokens returns all existing tokens for the user with the given user ID +func (a *Manager) Tokens(userID string) ([]*Token, error) { + rows, err := a.db.Query(a.queries.selectTokens, userID) + if err != nil { + return nil, err + } + defer rows.Close() + tokens := make([]*Token, 0) + for { + token, err := a.readToken(rows) + if errors.Is(err, ErrTokenNotFound) { + break + } else if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + return tokens, nil +} + +// AllProvisionedTokens returns all provisioned tokens +func (a *Manager) AllProvisionedTokens() ([]*Token, error) { + rows, err := a.db.Query(a.queries.selectAllProvisionedTokens) + if err != nil { + return nil, err + } + defer rows.Close() + tokens := make([]*Token, 0) + for { + token, err := a.readToken(rows) + if errors.Is(err, ErrTokenNotFound) { + break + } else if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + return tokens, nil +} + +// UpdateTokenLastAccess updates the last access time and origin for one or more tokens in a single transaction +func (a *Manager) UpdateTokenLastAccess(updates map[string]*TokenUpdate) error { + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for token, update := range updates { + if _, err := tx.Exec(a.queries.updateTokenLastAccess, update.LastAccess.Unix(), update.LastOrigin.String(), token); err != nil { + return err + } + } + return tx.Commit() +} + +// RemoveProvisionedToken deletes a provisioned token by value, regardless of user +func (a *Manager) RemoveProvisionedToken(token string) error { + if token == "" { + return errNoTokenProvided + } + if _, err := a.db.Exec(a.queries.deleteProvisionedToken, token); err != nil { + return err + } + return nil +} + +// RemoveExpiredTokens deletes all expired tokens from the database +func (a *Manager) RemoveExpiredTokens() error { + if _, err := a.db.Exec(a.queries.deleteExpiredTokens, time.Now().Unix()); err != nil { + return err + } + return nil +} + +// EnqueueTokenUpdate adds the token update to a queue which writes out token access times +// in batches at a regular interval +func (a *Manager) EnqueueTokenUpdate(tokenID string, update *TokenUpdate) { + a.mu.Lock() + defer a.mu.Unlock() + a.tokenQueue[tokenID] = update +} + +func (a *Manager) writeTokenUpdateQueue() error { + a.mu.Lock() + if len(a.tokenQueue) == 0 { + a.mu.Unlock() + log.Tag(tag).Trace("No token updates to commit") + return nil + } + tokenQueue := a.tokenQueue + a.tokenQueue = make(map[string]*TokenUpdate) + a.mu.Unlock() + + log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) + for tokenID, update := range tokenQueue { + log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) + } + return a.UpdateTokenLastAccess(tokenQueue) +} + +func (a *Manager) readToken(rows *sql.Rows) (*Token, error) { + var token, label, lastOrigin string + var lastAccess, expires int64 + var provisioned bool + if !rows.Next() { + return nil, ErrTokenNotFound + } + if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + lastOriginIP, err := netip.ParseAddr(lastOrigin) + if err != nil { + lastOriginIP = netip.IPv4Unspecified() + } + return &Token{ + Value: token, + Label: label, + LastAccess: time.Unix(lastAccess, 0), + LastOrigin: lastOriginIP, + Expires: time.Unix(expires, 0), + Provisioned: provisioned, + }, nil +} + // AddTier creates a new tier in the database func (a *Manager) AddTier(tier *Tier) error { if tier.ID == "" { @@ -1451,6 +1286,7 @@ func (a *Manager) RemovePhoneNumber(userID, phoneNumber string) error { _, err := a.db.Exec(a.queries.deletePhoneNumber, userID, phoneNumber) return err } + func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { var phoneNumber string if !rows.Next() { @@ -1472,21 +1308,147 @@ func (a *Manager) ChangeBilling(username string, billing *Billing) error { return nil } -// UserIDByUsername returns the user ID for the given username -func (a *Manager) UserIDByUsername(username string) (string, error) { - rows, err := a.db.Query(a.queries.selectUserIDFromUsername, username) +// maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config. +func (a *Manager) maybeProvisionUsersAccessAndTokens() error { + if !a.config.ProvisionEnabled { + return nil + } + existingUsers, err := a.Users() if err != nil { - return "", err + return err } - defer rows.Close() - if !rows.Next() { - return "", ErrUserNotFound + provisionUsernames := util.Map(a.config.Users, func(u *User) string { + return u.Name + }) + if err := a.maybeProvisionUsers(provisionUsernames, existingUsers); err != nil { + return fmt.Errorf("failed to provision users: %v", err) } - var userID string - if err := rows.Scan(&userID); err != nil { - return "", err + if err := a.maybeProvisionGrants(); err != nil { + return fmt.Errorf("failed to provision grants: %v", err) } - return userID, nil + if err := a.maybeProvisionTokens(provisionUsernames); err != nil { + return fmt.Errorf("failed to provision tokens: %v", err) + } + return nil +} + +// maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them. +// It also removes users that are provisioned, but not in the config anymore. +func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers []*User) error { + // Remove users that are provisioned, but not in the config anymore + for _, user := range existingUsers { + if user.Name == Everyone { + continue + } else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) { + if err := a.removeUser(user.Name); err != nil { + return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err) + } + } + } + // Add or update provisioned users + for _, user := range a.config.Users { + if user.Name == Everyone { + continue + } + existingUser, exists := util.Find(existingUsers, func(u *User) bool { + return u.Name == user.Name + }) + if !exists { + if err := a.addUser(user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { + return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) + } + } else { + if !existingUser.Provisioned { + if err := a.ChangeProvisioned(user.Name, true); err != nil { + return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err) + } + } + if existingUser.Hash != user.Hash { + if err := a.changePasswordHash(user.Name, user.Hash); err != nil { + return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) + } + } + if existingUser.Role != user.Role { + if err := a.changeRole(user.Name, user.Role); err != nil { + return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) + } + } + } + } + return nil +} + +// maybeProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config. +// +// Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last +// access time) or do not have dependent resources (such as grants or tokens). +func (a *Manager) maybeProvisionGrants() error { + // Remove all provisioned grants + if err := a.ResetAllProvisionedAccess(); err != nil { + return err + } + // (Re-)add provisioned grants + for username, grants := range a.config.Access { + user, exists := util.Find(a.config.Users, func(u *User) bool { + return u.Name == username + }) + if !exists && username != Everyone { + return fmt.Errorf("user %s is not a provisioned user, refusing to add ACL entry", username) + } else if user != nil && user.Role == RoleAdmin { + return fmt.Errorf("adding access control entries is not allowed for admin roles for user %s", username) + } + for _, grant := range grants { + if err := a.resetAccess(username, grant.TopicPattern); err != nil { + return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err) + } + if err := a.allowAccess(username, grant.TopicPattern, grant.Permission, true); err != nil { + return err + } + } + } + return nil +} + +func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { + // Remove tokens that are provisioned, but not in the config anymore + existingTokens, err := a.AllProvisionedTokens() + if err != nil { + return fmt.Errorf("failed to retrieve existing provisioned tokens: %v", err) + } + var provisionTokens []string + for _, userTokens := range a.config.Tokens { + for _, token := range userTokens { + provisionTokens = append(provisionTokens, token.Value) + } + } + for _, existingToken := range existingTokens { + if !slices.Contains(provisionTokens, existingToken.Value) { + if err := a.RemoveProvisionedToken(existingToken.Value); err != nil { + return fmt.Errorf("failed to remove provisioned token %s: %v", existingToken.Value, err) + } + } + } + // (Re-)add provisioned tokens + for username, tokens := range a.config.Tokens { + if !slices.Contains(provisionUsernames, username) && username != Everyone { + return fmt.Errorf("user %s is not a provisioned user, refusing to add tokens", username) + } + userID, err := a.UserIDByUsername(username) + if err != nil { + return fmt.Errorf("failed to find provisioned user %s for provisioned tokens: %v", username, err) + } + for _, token := range tokens { + if _, err := a.createToken(userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), 0, true); err != nil { + return err + } + } + } + return nil +} + +// Close closes the underlying database +func (a *Manager) Close() error { + return a.db.Close() } // isUniqueConstraintError checks if the error is a unique constraint violation for both SQLite and PostgreSQL From ccbd02331c667bbb6c8e761ce7bf38b4a77b9b92 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 28 Feb 2026 19:49:01 -0500 Subject: [PATCH 246/548] Re-add execTx --- user/manager.go | 307 +++++++++++++++++++++++------------------------- user/types.go | 6 - user/util.go | 32 +++++ 3 files changed, 176 insertions(+), 169 deletions(-) diff --git a/user/manager.go b/user/manager.go index d20836e6..152f5b6f 100644 --- a/user/manager.go +++ b/user/manager.go @@ -114,38 +114,27 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { // AddUser adds a user with the given username, password and role func (a *Manager) AddUser(username, password string, role Role, hashed bool) error { - return a.addUser(username, password, role, hashed, false) -} - -func (a *Manager) addUser(username, password string, role Role, hashed, provisioned bool) error { if !AllowedUsername(username) || !AllowedRole(role) { return ErrInvalidArgument } - var hash string - var err error - if hashed { - hash = password - if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { - return err - } - } else { - hash, err = hashPassword(password, a.config.BcryptCost) - if err != nil { - return err - } + hash, err := a.maybeHashPassword(password, hashed) + if err != nil { + return err } - return a.insertUser(username, hash, role, provisioned) + return execTx(a.db, func(tx *sql.Tx) error { + return a.addUserTx(tx, username, hash, role, false) + }) } -// insertUser adds a user with the given username, password hash and role to the database -func (a *Manager) insertUser(username, hash string, role Role, provisioned bool) error { +// addUserTx adds a user with the given username, password hash and role to the database +func (a *Manager) addUserTx(tx *sql.Tx, username, hash string, role Role, provisioned bool) error { if !AllowedUsername(username) || !AllowedRole(role) { return ErrInvalidArgument } userID := util.RandomStringPrefix(userIDPrefix, userIDLength) syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength) now := time.Now().Unix() - if _, err := a.db.Exec(a.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil { + if _, err := tx.Exec(a.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil { if isUniqueConstraintError(err) { return ErrUserExists } @@ -160,16 +149,18 @@ func (a *Manager) RemoveUser(username string) error { if err := a.CanChangeUser(username); err != nil { return err } - return a.removeUser(username) + return execTx(a.db, func(tx *sql.Tx) error { + return a.removeUserTx(tx, username) + }) } -// removeUser deletes the user with the given username -func (a *Manager) removeUser(username string) error { +// removeUserTx deletes the user with the given username +func (a *Manager) removeUserTx(tx *sql.Tx, username string) error { if !AllowedUsername(username) { return ErrInvalidArgument } // Rows in user_access, user_token, etc. are deleted via foreign keys - if _, err := a.db.Exec(a.queries.deleteUser, username); err != nil { + if _, err := tx.Exec(a.queries.deleteUser, username); err != nil { return err } return nil @@ -181,22 +172,19 @@ func (a *Manager) MarkUserRemoved(user *User) error { if !AllowedUsername(user.Name) { return ErrInvalidArgument } - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := a.resetUserAccessTx(tx, user.Name); err != nil { - return err - } - if _, err := tx.Exec(a.queries.deleteAllToken, user.ID); err != nil { - return err - } - deletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix() - if _, err := tx.Exec(a.queries.updateUserDeleted, deletedTime, user.ID); err != nil { - return err - } - return tx.Commit() + return execTx(a.db, func(tx *sql.Tx) error { + if err := a.resetUserAccessTx(tx, user.Name); err != nil { + return err + } + if _, err := tx.Exec(a.queries.deleteAllToken, user.ID); err != nil { + return err + } + deletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix() + if _, err := tx.Exec(a.queries.updateUserDeleted, deletedTime, user.ID); err != nil { + return err + } + return nil + }) } // RemoveDeletedUsers deletes all users that have been marked deleted @@ -212,25 +200,18 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { if err := a.CanChangeUser(username); err != nil { return err } - var hash string - var err error - if hashed { - hash = password - if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil { - return err - } - } else { - hash, err = hashPassword(password, a.config.BcryptCost) - if err != nil { - return err - } + hash, err := a.maybeHashPassword(password, hashed) + if err != nil { + return err } - return a.changePasswordHash(username, hash) + return execTx(a.db, func(tx *sql.Tx) error { + return a.changePasswordHashTx(tx, username, hash) + }) } -// changePasswordHash changes a user's password hash in the database -func (a *Manager) changePasswordHash(username, hash string) error { - if _, err := a.db.Exec(a.queries.updateUserPass, hash, username); err != nil { +// changePasswordHashTx changes a user's password hash in the database +func (a *Manager) changePasswordHashTx(tx *sql.Tx, username, hash string) error { + if _, err := tx.Exec(a.queries.updateUserPass, hash, username); err != nil { return err } return nil @@ -242,19 +223,16 @@ func (a *Manager) ChangeRole(username string, role Role) error { if err := a.CanChangeUser(username); err != nil { return err } - return a.changeRole(username, role) + return execTx(a.db, func(tx *sql.Tx) error { + return a.changeRoleTx(tx, username, role) + }) } -// changeRole changes a user's role -func (a *Manager) changeRole(username string, role Role) error { +// changeRoleTx changes a user's role +func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error { if !AllowedUsername(username) || !AllowedRole(role) { return ErrInvalidArgument } - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() if _, err := tx.Exec(a.queries.updateUserRole, string(role), username); err != nil { return err } @@ -264,7 +242,7 @@ func (a *Manager) changeRole(username string, role Role) error { return err } } - return tx.Commit() + return nil } // CanChangeUser checks if the user with the given username can be changed. @@ -281,7 +259,14 @@ func (a *Manager) CanChangeUser(username string) error { // ChangeProvisioned changes the provisioned status of a user func (a *Manager) ChangeProvisioned(username string, provisioned bool) error { - if _, err := a.db.Exec(a.queries.updateUserProvisioned, provisioned, username); err != nil { + return execTx(a.db, func(tx *sql.Tx) error { + return a.changeProvisionedTx(tx, username, provisioned) + }) +} + +// changeProvisionedTx changes the provisioned status of a user +func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error { + if _, err := tx.Exec(a.queries.updateUserProvisioned, provisioned, username); err != nil { return err } return nil @@ -357,17 +342,14 @@ func (a *Manager) ResetStats() error { // UpdateStats updates statistics for one or more users in a single transaction func (a *Manager) UpdateStats(stats map[string]*Stats) error { - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for userID, update := range stats { - if _, err := tx.Exec(a.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { - return err + return execTx(a.db, func(tx *sql.Tx) error { + for userID, update := range stats { + if _, err := tx.Exec(a.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { + return err + } } - } - return tx.Commit() + return nil + }) } // EnqueueUserStats adds the user to a queue which writes out user stats (messages, emails, ..) in @@ -578,6 +560,16 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { return user, nil } +func (a *Manager) maybeHashPassword(password string, hashed bool) (string, error) { + if hashed { + if err := ValidPasswordHash(password, a.config.BcryptCost); err != nil { + return "", err + } + return password, nil + } + return hashPassword(password, a.config.BcryptCost) +} + // Authorize returns nil if the given user has access to the given topic using the desired // permission. The user param may be nil to signal an anonymous user. func (a *Manager) Authorize(user *User, topic string, perm Permission) error { @@ -612,37 +604,42 @@ func (a *Manager) resolvePerms(base, perm Permission) error { // read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry // owner may either be a user (username), or the system (empty). func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { - return a.allowAccess(username, topicPattern, permission, false) + return execTx(a.db, func(tx *sql.Tx) error { + return a.allowAccessTx(tx, username, topicPattern, permission, false) + }) } -func (a *Manager) allowAccess(username string, topicPattern string, permission Permission, provisioned bool) error { +func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error { if !AllowedUsername(username) && username != Everyone { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) { return ErrInvalidArgument } - return a.allowAccessTx(a.db, username, topicPattern, permission.IsRead(), permission.IsWrite(), "", provisioned) + _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), "", "", provisioned) + return err } // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is // empty) for an entire user. The parameter topicPattern may include wildcards (*). func (a *Manager) ResetAccess(username string, topicPattern string) error { - return a.resetAccess(username, topicPattern) + return execTx(a.db, func(tx *sql.Tx) error { + return a.resetAccessTx(tx, username, topicPattern) + }) } -func (a *Manager) resetAccess(username string, topicPattern string) error { +func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error { if !AllowedUsername(username) && username != Everyone && username != "" { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { return ErrInvalidArgument } if username == "" && topicPattern == "" { - _, err := a.db.Exec(a.queries.deleteAllAccess) + _, err := tx.Exec(a.queries.deleteAllAccess) return err } else if topicPattern == "" { - return a.resetUserAccessTx(a.db, username) + return a.resetUserAccessTx(tx, username) } - return a.resetTopicAccessTx(a.db, username, topicPattern) + return a.resetTopicAccessTx(tx, username, topicPattern) } // DefaultAccess returns the default read/write access if no access control entry matches @@ -749,18 +746,12 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { return ErrInvalidArgument } - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := a.allowAccessTx(tx, username, topic, true, true, username, false); err != nil { - return err - } - if err := a.allowAccessTx(tx, Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username, false); err != nil { - return err - } - return tx.Commit() + return execTx(a.db, func(tx *sql.Tx) error { + if err := a.addReservationAccessTx(tx, username, topic, true, true, username); err != nil { + return err + } + return a.addReservationAccessTx(tx, Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username) + }) } // RemoveReservations deletes the access control entries associated with the given username/topic, @@ -775,20 +766,17 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { return ErrInvalidArgument } } - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, topic := range topics { - if err := a.resetTopicAccessTx(tx, username, topic); err != nil { - return err + return execTx(a.db, func(tx *sql.Tx) error { + for _, topic := range topics { + if err := a.resetTopicAccessTx(tx, username, topic); err != nil { + return err + } + if err := a.resetTopicAccessTx(tx, Everyone, topic); err != nil { + return err + } } - if err := a.resetTopicAccessTx(tx, Everyone, topic); err != nil { - return err - } - } - return tx.Commit() + return nil + }) } // Reservations returns all user-owned topics, and the associated everyone-access @@ -893,17 +881,17 @@ func (a *Manager) ResetAllProvisionedAccess() error { return nil } -func (a *Manager) allowAccessTx(tx execer, username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error { +func (a *Manager) addReservationAccessTx(tx *sql.Tx, username, topic string, read, write bool, ownerUsername string) error { if !AllowedUsername(username) && username != Everyone { return ErrInvalidArgument - } else if !AllowedTopicPattern(topicPattern) { + } else if !AllowedTopicPattern(topic) { return ErrInvalidArgument } - _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned) + _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topic), read, write, ownerUsername, ownerUsername, false) return err } -func (a *Manager) resetUserAccessTx(tx execer, username string) error { +func (a *Manager) resetUserAccessTx(tx *sql.Tx, username string) error { if !AllowedUsername(username) && username != Everyone { return ErrInvalidArgument } @@ -911,7 +899,7 @@ func (a *Manager) resetUserAccessTx(tx execer, username string) error { return err } -func (a *Manager) resetTopicAccessTx(tx execer, username, topicPattern string) error { +func (a *Manager) resetTopicAccessTx(tx *sql.Tx, username, topicPattern string) error { if !AllowedUsername(username) && username != Everyone && username != "" { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { @@ -925,17 +913,14 @@ func (a *Manager) resetTopicAccessTx(tx execer, username, topicPattern string) e // after a fixed duration unless ChangeToken is called. This function also prunes tokens for the // given user, if there are too many of them. func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) { - return a.createToken(userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned) + return queryTx(a.db, func(tx *sql.Tx) (*Token, error) { + return a.createTokenTx(tx, userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned) + }) } -// createToken creates a new token and prunes excess tokens if the count exceeds maxTokenCount. +// createTokenTx creates a new token and prunes excess tokens if the count exceeds maxTokenCount. // If maxTokenCount is 0, no pruning is performed. -func (a *Manager) createToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) { - tx, err := a.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() +func (a *Manager) createTokenTx(tx *sql.Tx, userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, maxTokenCount int, provisioned bool) (*Token, error) { if _, err := tx.Exec(a.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil { return nil, err } @@ -950,9 +935,6 @@ func (a *Manager) createToken(userID, token, label string, lastAccess time.Time, } } } - if err := tx.Commit(); err != nil { - return nil, err - } return &Token{ Value: token, Label: label, @@ -1064,17 +1046,14 @@ func (a *Manager) AllProvisionedTokens() ([]*Token, error) { // UpdateTokenLastAccess updates the last access time and origin for one or more tokens in a single transaction func (a *Manager) UpdateTokenLastAccess(updates map[string]*TokenUpdate) error { - tx, err := a.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for token, update := range updates { - if _, err := tx.Exec(a.queries.updateTokenLastAccess, update.LastAccess.Unix(), update.LastOrigin.String(), token); err != nil { - return err + return execTx(a.db, func(tx *sql.Tx) error { + for token, update := range updates { + if _, err := tx.Exec(a.queries.updateTokenLastAccess, update.LastAccess.Unix(), update.LastOrigin.String(), token); err != nil { + return err + } } - } - return tx.Commit() + return nil + }) } // RemoveProvisionedToken deletes a provisioned token by value, regardless of user @@ -1320,27 +1299,33 @@ func (a *Manager) maybeProvisionUsersAccessAndTokens() error { provisionUsernames := util.Map(a.config.Users, func(u *User) string { return u.Name }) - if err := a.maybeProvisionUsers(provisionUsernames, existingUsers); err != nil { - return fmt.Errorf("failed to provision users: %v", err) + existingTokens, err := a.AllProvisionedTokens() + if err != nil { + return err } - if err := a.maybeProvisionGrants(); err != nil { - return fmt.Errorf("failed to provision grants: %v", err) - } - if err := a.maybeProvisionTokens(provisionUsernames); err != nil { - return fmt.Errorf("failed to provision tokens: %v", err) - } - return nil + return execTx(a.db, func(tx *sql.Tx) error { + if err := a.maybeProvisionUsers(tx, provisionUsernames, existingUsers); err != nil { + return fmt.Errorf("failed to provision users: %v", err) + } + if err := a.maybeProvisionGrants(tx); err != nil { + return fmt.Errorf("failed to provision grants: %v", err) + } + if err := a.maybeProvisionTokens(tx, provisionUsernames, existingTokens); err != nil { + return fmt.Errorf("failed to provision tokens: %v", err) + } + return nil + }) } // maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them. // It also removes users that are provisioned, but not in the config anymore. -func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers []*User) error { +func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, existingUsers []*User) error { // Remove users that are provisioned, but not in the config anymore for _, user := range existingUsers { if user.Name == Everyone { continue } else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) { - if err := a.removeUser(user.Name); err != nil { + if err := a.removeUserTx(tx, user.Name); err != nil { return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err) } } @@ -1354,22 +1339,22 @@ func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers return u.Name == user.Name }) if !exists { - if err := a.addUser(user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { + if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true); err != nil && !errors.Is(err, ErrUserExists) { return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) } } else { if !existingUser.Provisioned { - if err := a.ChangeProvisioned(user.Name, true); err != nil { + if err := a.changeProvisionedTx(tx, user.Name, true); err != nil { return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err) } } if existingUser.Hash != user.Hash { - if err := a.changePasswordHash(user.Name, user.Hash); err != nil { + if err := a.changePasswordHashTx(tx, user.Name, user.Hash); err != nil { return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) } } if existingUser.Role != user.Role { - if err := a.changeRole(user.Name, user.Role); err != nil { + if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil { return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) } } @@ -1382,9 +1367,9 @@ func (a *Manager) maybeProvisionUsers(provisionUsernames []string, existingUsers // // Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last // access time) or do not have dependent resources (such as grants or tokens). -func (a *Manager) maybeProvisionGrants() error { +func (a *Manager) maybeProvisionGrants(tx *sql.Tx) error { // Remove all provisioned grants - if err := a.ResetAllProvisionedAccess(); err != nil { + if _, err := tx.Exec(a.queries.deleteUserAccessProvisioned); err != nil { return err } // (Re-)add provisioned grants @@ -1398,10 +1383,10 @@ func (a *Manager) maybeProvisionGrants() error { return fmt.Errorf("adding access control entries is not allowed for admin roles for user %s", username) } for _, grant := range grants { - if err := a.resetAccess(username, grant.TopicPattern); err != nil { + if err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil { return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err) } - if err := a.allowAccess(username, grant.TopicPattern, grant.Permission, true); err != nil { + if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil { return err } } @@ -1409,12 +1394,8 @@ func (a *Manager) maybeProvisionGrants() error { return nil } -func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { +func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string, existingTokens []*Token) error { // Remove tokens that are provisioned, but not in the config anymore - existingTokens, err := a.AllProvisionedTokens() - if err != nil { - return fmt.Errorf("failed to retrieve existing provisioned tokens: %v", err) - } var provisionTokens []string for _, userTokens := range a.config.Tokens { for _, token := range userTokens { @@ -1423,7 +1404,7 @@ func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { } for _, existingToken := range existingTokens { if !slices.Contains(provisionTokens, existingToken.Value) { - if err := a.RemoveProvisionedToken(existingToken.Value); err != nil { + if _, err := tx.Exec(a.queries.deleteProvisionedToken, existingToken.Value); err != nil { return fmt.Errorf("failed to remove provisioned token %s: %v", existingToken.Value, err) } } @@ -1433,12 +1414,12 @@ func (a *Manager) maybeProvisionTokens(provisionUsernames []string) error { if !slices.Contains(provisionUsernames, username) && username != Everyone { return fmt.Errorf("user %s is not a provisioned user, refusing to add tokens", username) } - userID, err := a.UserIDByUsername(username) - if err != nil { + var userID string + if err := tx.QueryRow(a.queries.selectUserIDFromUsername, username).Scan(&userID); err != nil { return fmt.Errorf("failed to find provisioned user %s for provisioned tokens: %v", username, err) } for _, token := range tokens { - if _, err := a.createToken(userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), 0, true); err != nil { + if _, err := a.createTokenTx(tx, userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), time.Unix(0, 0), 0, true); err != nil { return err } } diff --git a/user/types.go b/user/types.go index f5d5e70e..b80bc46b 100644 --- a/user/types.go +++ b/user/types.go @@ -1,7 +1,6 @@ package user import ( - "database/sql" "errors" "net/netip" "strings" @@ -345,8 +344,3 @@ type storeQueries struct { updateBilling string } -// execer is satisfied by both *sql.DB and *sql.Tx, allowing helper methods -// to be used both standalone and within a transaction. -type execer interface { - Exec(query string, args ...any) (sql.Result, error) -} diff --git a/user/util.go b/user/util.go index 16f6cc09..6354464d 100644 --- a/user/util.go +++ b/user/util.go @@ -113,3 +113,35 @@ func escapeUnderscore(s string) string { func unescapeUnderscore(s string) string { return strings.ReplaceAll(s, "\\_", "_") } + +// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back. +func execTx(db *sql.DB, f func(tx *sql.Tx) error) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := f(tx); err != nil { + return err + } + return tx.Commit() +} + +// queryTx executes a function in a transaction and returns the result. If the function +// returns an error, the transaction is rolled back. +func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) { + tx, err := db.Begin() + if err != nil { + var zero T + return zero, err + } + defer tx.Rollback() + t, err := f(tx) + if err != nil { + return t, err + } + if err := tx.Commit(); err != nil { + return t, err + } + return t, nil +} From c19377109eebbeb2d80d50dd0134c61fbf5d301c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 28 Feb 2026 21:15:51 -0500 Subject: [PATCH 247/548] Small refinements --- user/manager.go | 28 ++++++++-------------------- user/manager_test.go | 8 ++++---- user/types.go | 1 - 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/user/manager.go b/user/manager.go index 152f5b6f..f991d296 100644 --- a/user/manager.go +++ b/user/manager.go @@ -103,7 +103,7 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { if len(token) != tokenLength { return nil, ErrUnauthenticated } - user, err := a.UserByToken(token) + user, err := a.userByToken(token) if err != nil { log.Tag(tag).Field("token", token).Err(err).Trace("Authentication of token failed") return nil, ErrUnauthenticated @@ -114,9 +114,6 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { // AddUser adds a user with the given username, password and role func (a *Manager) AddUser(username, password string, role Role, hashed bool) error { - if !AllowedUsername(username) || !AllowedRole(role) { - return ErrInvalidArgument - } hash, err := a.maybeHashPassword(password, hashed) if err != nil { return err @@ -416,8 +413,8 @@ func (a *Manager) UserByID(id string) (*User, error) { return a.readUser(rows) } -// UserByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise -func (a *Manager) UserByToken(token string) (*User, error) { +// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise +func (a *Manager) userByToken(token string) (*User, error) { rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix()) if err != nil { return nil, err @@ -581,7 +578,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { username = user.Name } // Select the read/write permissions for this user/topic combo. - read, write, found, err := a.AuthorizeTopicAccess(username, topic) + read, write, found, err := a.authorizeTopicAccess(username, topic) if err != nil { return err } @@ -663,13 +660,13 @@ func (a *Manager) AllowReservation(username string, topic string) error { return nil } -// AuthorizeTopicAccess returns the read/write permissions for the given username and topic. +// authorizeTopicAccess returns the read/write permissions for the given username and topic. // The found return value indicates whether an ACL entry was found at all. // // - The query may return two rows (one for everyone, and one for the user), but prioritizes the user. // - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" // - It also prioritizes write permissions over read permissions -func (a *Manager) AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) { +func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) { rows, err := a.db.Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic) if err != nil { return false, false, false, err @@ -873,14 +870,6 @@ func (a *Manager) OtherAccessCount(username, topic string) (int, error) { return count, nil } -// ResetAllProvisionedAccess removes all provisioned access control entries -func (a *Manager) ResetAllProvisionedAccess() error { - if _, err := a.db.Exec(a.queries.deleteUserAccessProvisioned); err != nil { - return err - } - return nil -} - func (a *Manager) addReservationAccessTx(tx *sql.Tx, username, topic string, read, write bool, ownerUsername string) error { if !AllowedUsername(username) && username != Everyone { return ErrInvalidArgument @@ -1024,8 +1013,7 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) { return tokens, nil } -// AllProvisionedTokens returns all provisioned tokens -func (a *Manager) AllProvisionedTokens() ([]*Token, error) { +func (a *Manager) allProvisionedTokens() ([]*Token, error) { rows, err := a.db.Query(a.queries.selectAllProvisionedTokens) if err != nil { return nil, err @@ -1299,7 +1287,7 @@ func (a *Manager) maybeProvisionUsersAccessAndTokens() error { provisionUsernames := util.Map(a.config.Users, func(u *User) string { return u.Name }) - existingTokens, err := a.AllProvisionedTokens() + existingTokens, err := a.allProvisionedTokens() if err != nil { return err } diff --git a/user/manager_test.go b/user/manager_test.go index 8ba6fe3b..64ea437a 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1797,7 +1797,7 @@ func TestStoreUserByToken(t *testing.T) { require.Nil(t, err) require.NotEmpty(t, tk.Value) - u2, err := manager.UserByToken(tk.Value) + u2, err := manager.userByToken(tk.Value) require.Nil(t, err) require.Equal(t, "phil", u2.Name) }) @@ -2054,7 +2054,7 @@ func TestStoreAuthorizeTopicAccess(t *testing.T) { require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) require.Nil(t, manager.AllowAccess("phil", "mytopic", PermissionReadWrite)) - read, write, found, err := manager.AuthorizeTopicAccess("phil", "mytopic") + read, write, found, err := manager.authorizeTopicAccess("phil", "mytopic") require.Nil(t, err) require.True(t, found) require.True(t, read) @@ -2066,7 +2066,7 @@ func TestStoreAuthorizeTopicAccessNotFound(t *testing.T) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) { require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) - _, _, found, err := manager.AuthorizeTopicAccess("phil", "other") + _, _, found, err := manager.authorizeTopicAccess("phil", "other") require.Nil(t, err) require.False(t, found) }) @@ -2077,7 +2077,7 @@ func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) { require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) require.Nil(t, manager.AllowAccess("phil", "secret", PermissionDenyAll)) - read, write, found, err := manager.AuthorizeTopicAccess("phil", "secret") + read, write, found, err := manager.authorizeTopicAccess("phil", "secret") require.Nil(t, err) require.True(t, found) require.False(t, read) diff --git a/user/types.go b/user/types.go index b80bc46b..58781203 100644 --- a/user/types.go +++ b/user/types.go @@ -343,4 +343,3 @@ type storeQueries struct { // Billing queries updateBilling string } - From 544ce112b59d9af8bae9594e2903ff283a9ff523 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Mar 2026 11:44:22 -0500 Subject: [PATCH 248/548] Manual review --- user/manager.go | 15 +++++++-------- user/manager_postgres.go | 8 +++++--- user/manager_sqlite.go | 8 +++++--- user/manager_test.go | 2 +- user/types.go | 4 ++-- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/user/manager.go b/user/manager.go index f991d296..fd2ac819 100644 --- a/user/manager.go +++ b/user/manager.go @@ -49,7 +49,7 @@ var ( type Manager struct { config *Config db *sql.DB - queries storeQueries + queries queries statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) mu sync.Mutex @@ -65,8 +65,6 @@ func initManager(manager *Manager) error { if manager.config.QueueWriterInterval.Seconds() <= 0 { manager.config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } - manager.statsQueue = make(map[string]*Stats) - manager.tokenQueue = make(map[string]*TokenUpdate) if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil { return err } @@ -581,8 +579,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { read, write, found, err := a.authorizeTopicAccess(username, topic) if err != nil { return err - } - if !found { + } else if !found { return a.resolvePerms(a.config.DefaultAccess, perm) } return a.resolvePerms(NewPermission(read, write), perm) @@ -650,7 +647,7 @@ func (a *Manager) AllowReservation(username string, topic string) error { if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { return ErrInvalidArgument } - otherCount, err := a.OtherAccessCount(username, topic) + otherCount, err := a.otherAccessCount(username, topic) if err != nil { return err } @@ -853,8 +850,8 @@ func (a *Manager) ReservationOwner(topic string) (string, error) { return ownerUserID, nil } -// OtherAccessCount returns the number of access entries for the given topic that are not owned by the user -func (a *Manager) OtherAccessCount(username, topic string) (int, error) { +// otherAccessCount returns the number of access entries for the given topic that are not owned by the user +func (a *Manager) otherAccessCount(username, topic string) (int, error) { rows, err := a.db.Query(a.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username) if err != nil { return 0, err @@ -919,6 +916,8 @@ func (a *Manager) createTokenTx(tx *sql.Tx, userID, token, label string, lastAcc return nil, err } if tokenCount > maxTokenCount { + // This pruning logic is done in two queries for efficiency. The SELECT above is a lookup + // on two indices, whereas the query below is a full table scan. if _, err := tx.Exec(a.queries.deleteExcessTokens, userID, userID, maxTokenCount); err != nil { return nil, err } diff --git a/user/manager_postgres.go b/user/manager_postgres.go index e396fbea..b7ef2d08 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -209,9 +209,11 @@ func NewPostgresManager(db *sql.DB, config *Config) (*Manager, error) { return nil, err } manager := &Manager{ - config: config, - db: db, - queries: storeQueries{ + config: config, + db: db, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), + queries: queries{ // User queries selectUserByID: postgresSelectUserByIDQuery, selectUserByName: postgresSelectUserByNameQuery, diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 7ca6e711..2d33087f 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -218,9 +218,11 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager return nil, err } manager := &Manager{ - config: config, - db: db, - queries: storeQueries{ + config: config, + db: db, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), + queries: queries{ selectUserByID: sqliteSelectUserByIDQuery, selectUserByName: sqliteSelectUserByNameQuery, selectUserByToken: sqliteSelectUserByTokenQuery, diff --git a/user/manager_test.go b/user/manager_test.go index 64ea437a..dbdbd311 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -2402,7 +2402,7 @@ func TestStoreOtherAccessCount(t *testing.T) { require.Nil(t, manager.AddUser("ben", "benpass", RoleUser, false)) require.Nil(t, manager.AddReservation("ben", "mytopic", PermissionReadWrite)) - count, err := manager.OtherAccessCount("phil", "mytopic") + count, err := manager.otherAccessCount("phil", "mytopic") require.Nil(t, err) require.Equal(t, 2, count) // ben's owner entry + everyone entry }) diff --git a/user/types.go b/user/types.go index 58781203..e909ec78 100644 --- a/user/types.go +++ b/user/types.go @@ -275,8 +275,8 @@ var ( ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") ) -// storeQueries holds the database-specific SQL queries -type storeQueries struct { +// queries holds the database-specific SQL queries +type queries struct { // User queries selectUserByID string selectUserByName string From 039566bcaf1eb81eb19b84f7c5b1b1616c7e2b81 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Mar 2026 11:59:24 -0500 Subject: [PATCH 249/548] REmove unused --- user/manager.go | 110 +++++++++++++------------------------------ user/manager_test.go | 10 ++-- 2 files changed, 40 insertions(+), 80 deletions(-) diff --git a/user/manager.go b/user/manager.go index fd2ac819..a8e8dba3 100644 --- a/user/manager.go +++ b/user/manager.go @@ -252,13 +252,6 @@ func (a *Manager) CanChangeUser(username string) error { return nil } -// ChangeProvisioned changes the provisioned status of a user -func (a *Manager) ChangeProvisioned(username string, provisioned bool) error { - return execTx(a.db, func(tx *sql.Tx) error { - return a.changeProvisionedTx(tx, username, provisioned) - }) -} - // changeProvisionedTx changes the provisioned status of a user func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error { if _, err := tx.Exec(a.queries.updateUserProvisioned, provisioned, username); err != nil { @@ -335,18 +328,6 @@ func (a *Manager) ResetStats() error { return nil } -// UpdateStats updates statistics for one or more users in a single transaction -func (a *Manager) UpdateStats(stats map[string]*Stats) error { - return execTx(a.db, func(tx *sql.Tx) error { - for userID, update := range stats { - if _, err := tx.Exec(a.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { - return err - } - } - return nil - }) -} - // EnqueueUserStats adds the user to a queue which writes out user stats (messages, emails, ..) in // batches at a regular interval func (a *Manager) EnqueueUserStats(userID string, stats *Stats) { @@ -378,19 +359,24 @@ func (a *Manager) writeUserStatsQueue() error { a.statsQueue = make(map[string]*Stats) a.mu.Unlock() - log.Tag(tag).Debug("Writing user stats queue for %d user(s)", len(statsQueue)) - for userID, update := range statsQueue { - log. - Tag(tag). - Fields(log.Context{ - "user_id": userID, - "messages_count": update.Messages, - "emails_count": update.Emails, - "calls_count": update.Calls, - }). - Trace("Updating stats for user %s", userID) - } - return a.UpdateStats(statsQueue) + return execTx(a.db, func(tx *sql.Tx) error { + log.Tag(tag).Debug("Writing user stats queue for %d user(s)", len(statsQueue)) + for userID, update := range statsQueue { + log. + Tag(tag). + Fields(log.Context{ + "user_id": userID, + "messages_count": update.Messages, + "emails_count": update.Emails, + "calls_count": update.Calls, + }). + Trace("Updating stats for user %s", userID) + if _, err := tx.Exec(a.queries.updateUserStats, update.Messages, update.Emails, update.Calls, userID); err != nil { + return err + } + } + return nil + }) } // User returns the user with the given username if it exists, or ErrUserNotFound otherwise @@ -475,23 +461,6 @@ func (a *Manager) UsersCount() (int64, error) { return count, nil } -// UserIDByUsername returns the user ID for the given username -func (a *Manager) UserIDByUsername(username string) (string, error) { - rows, err := a.db.Query(a.queries.selectUserIDFromUsername, username) - if err != nil { - return "", err - } - defer rows.Close() - if !rows.Next() { - return "", ErrUserNotFound - } - var userID string - if err := rows.Scan(&userID); err != nil { - return "", err - } - return userID, nil -} - func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var id, username, hash, role, prefs, syncTopic string @@ -1031,29 +1000,6 @@ func (a *Manager) allProvisionedTokens() ([]*Token, error) { return tokens, nil } -// UpdateTokenLastAccess updates the last access time and origin for one or more tokens in a single transaction -func (a *Manager) UpdateTokenLastAccess(updates map[string]*TokenUpdate) error { - return execTx(a.db, func(tx *sql.Tx) error { - for token, update := range updates { - if _, err := tx.Exec(a.queries.updateTokenLastAccess, update.LastAccess.Unix(), update.LastOrigin.String(), token); err != nil { - return err - } - } - return nil - }) -} - -// RemoveProvisionedToken deletes a provisioned token by value, regardless of user -func (a *Manager) RemoveProvisionedToken(token string) error { - if token == "" { - return errNoTokenProvided - } - if _, err := a.db.Exec(a.queries.deleteProvisionedToken, token); err != nil { - return err - } - return nil -} - // RemoveExpiredTokens deletes all expired tokens from the database func (a *Manager) RemoveExpiredTokens() error { if _, err := a.db.Exec(a.queries.deleteExpiredTokens, time.Now().Unix()); err != nil { @@ -1081,11 +1027,23 @@ func (a *Manager) writeTokenUpdateQueue() error { a.tokenQueue = make(map[string]*TokenUpdate) a.mu.Unlock() - log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) - for tokenID, update := range tokenQueue { - log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) + return execTx(a.db, func(tx *sql.Tx) error { + log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) + for tokenID, update := range tokenQueue { + log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) + if err := a.updateTokenLastAccessTx(tx, tokenID, update.LastAccess.Unix(), update.LastOrigin.String()); err != nil { + return err + } + } + return nil + }) +} + +func (a *Manager) updateTokenLastAccessTx(tx *sql.Tx, token string, lastAccess int64, lastOrigin string) error { + if _, err := tx.Exec(a.queries.updateTokenLastAccess, lastAccess, lastOrigin, token); err != nil { + return err } - return a.UpdateTokenLastAccess(tokenQueue) + return nil } func (a *Manager) readToken(rows *sql.Rows) (*Token, error) { diff --git a/user/manager_test.go b/user/manager_test.go index dbdbd311..f566e082 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1218,7 +1218,8 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Update the token last access time and origin (so we can check that it is persisted) lastAccessTime := time.Now().Add(time.Hour) lastOrigin := netip.MustParseAddr("1.1.9.9") - err = a.UpdateTokenLastAccess(map[string]*TokenUpdate{tokens[0].Value: {LastAccess: lastAccessTime, LastOrigin: lastOrigin}}) + a.EnqueueTokenUpdate(tokens[0].Value, &TokenUpdate{LastAccess: lastAccessTime, LastOrigin: lastOrigin}) + err = a.writeTokenUpdateQueue() require.Nil(t, err) // Re-open the DB (second app start) @@ -2318,8 +2319,8 @@ func TestStoreUpdateStats(t *testing.T) { u, err := manager.User("phil") require.Nil(t, err) - stats := &Stats{Messages: 42, Emails: 3, Calls: 1} - require.Nil(t, manager.UpdateStats(map[string]*Stats{u.ID: stats})) + manager.EnqueueUserStats(u.ID, &Stats{Messages: 42, Emails: 3, Calls: 1}) + require.Nil(t, manager.writeUserStatsQueue()) u2, err := manager.User("phil") require.Nil(t, err) @@ -2335,7 +2336,8 @@ func TestStoreResetStats(t *testing.T) { u, err := manager.User("phil") require.Nil(t, err) - require.Nil(t, manager.UpdateStats(map[string]*Stats{u.ID: {Messages: 42, Emails: 3, Calls: 1}})) + manager.EnqueueUserStats(u.ID, &Stats{Messages: 42, Emails: 3, Calls: 1}) + require.Nil(t, manager.writeUserStatsQueue()) require.Nil(t, manager.ResetStats()) u2, err := manager.User("phil") From 9736973286699748c72b702120f5dfe36753883f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Mar 2026 13:19:53 -0500 Subject: [PATCH 250/548] REmove store interface --- message/{store.go => cache.go} | 109 ++++++++---------- .../{store_postgres.go => cache_postgres.go} | 6 +- ...res_schema.go => cache_postgres_schema.go} | 0 message/{store_sqlite.go => cache_sqlite.go} | 10 +- ...qlite_schema.go => cache_sqlite_schema.go} | 0 ...re_sqlite_test.go => cache_sqlite_test.go} | 2 +- message/{store_test.go => cache_test.go} | 46 ++++---- server/server.go | 8 +- server/server_test.go | 2 +- server/visitor.go | 4 +- webpush/store.go | 43 +++---- webpush/store_postgres.go | 6 +- webpush/store_sqlite.go | 6 +- webpush/store_test.go | 24 ++-- 14 files changed, 122 insertions(+), 144 deletions(-) rename message/{store.go => cache.go} (79%) rename message/{store_postgres.go => cache_postgres.go} (97%) rename message/{store_postgres_schema.go => cache_postgres_schema.go} (100%) rename message/{store_sqlite.go => cache_sqlite.go} (97%) rename message/{store_sqlite_schema.go => cache_sqlite_schema.go} (100%) rename message/{store_sqlite_test.go => cache_sqlite_test.go} (99%) rename message/{store_test.go => cache_test.go} (95%) diff --git a/message/store.go b/message/cache.go similarity index 79% rename from message/store.go rename to message/cache.go index 11d261f0..953a6f7f 100644 --- a/message/store.go +++ b/message/cache.go @@ -20,32 +20,8 @@ const ( var errNoRows = errors.New("no rows found") -// Store is the interface for a message cache store -type Store interface { - AddMessage(m *model.Message) error - AddMessages(ms []*model.Message) error - Message(id string) (*model.Message, error) - MessagesCount() (int, error) - Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) - MessagesDue() ([]*model.Message, error) - MessagesExpired() ([]string, error) - MarkPublished(m *model.Message) error - UpdateMessageTime(messageID string, timestamp int64) error - Topics() ([]string, error) - DeleteMessages(ids ...string) error - DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) - ExpireMessages(topics ...string) error - AttachmentsExpired() ([]string, error) - MarkAttachmentsDeleted(ids ...string) error - AttachmentBytesUsedBySender(sender string) (int64, error) - AttachmentBytesUsedByUser(userID string) (int64, error) - UpdateStats(messages int64) error - Stats() (int64, error) - Close() error -} - -// storeQueries holds the database-specific SQL queries -type storeQueries struct { +// queries holds the database-specific SQL queries +type queries struct { insertMessage string deleteMessage string selectScheduledMessageIDsBySeqID string @@ -71,21 +47,21 @@ type storeQueries struct { updateMessageTime string } -// commonStore implements store operations that are identical across database backends -type commonStore struct { +// Cache stores published messages +type Cache struct { db *sql.DB queue *util.BatchingQueue[*model.Message] nop bool mu *sync.Mutex // nil for PostgreSQL (concurrent writes supported), set for SQLite (single writer) - queries storeQueries + queries queries } -func newCommonStore(db *sql.DB, queries storeQueries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *commonStore { +func newCache(db *sql.DB, queries queries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *Cache { var queue *util.BatchingQueue[*model.Message] if batchSize > 0 || batchTimeout > 0 { queue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout) } - c := &commonStore{ + c := &Cache{ db: db, queue: queue, nop: nop, @@ -96,13 +72,13 @@ func newCommonStore(db *sql.DB, queries storeQueries, mu *sync.Mutex, batchSize return c } -func (c *commonStore) maybeLock() { +func (c *Cache) maybeLock() { if c.mu != nil { c.mu.Lock() } } -func (c *commonStore) maybeUnlock() { +func (c *Cache) maybeUnlock() { if c.mu != nil { c.mu.Unlock() } @@ -110,7 +86,7 @@ func (c *commonStore) maybeUnlock() { // AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asynchronously. // The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor. -func (c *commonStore) AddMessage(m *model.Message) error { +func (c *Cache) AddMessage(m *model.Message) error { if c.queue != nil { c.queue.Enqueue(m) return nil @@ -119,11 +95,11 @@ func (c *commonStore) AddMessage(m *model.Message) error { } // AddMessages synchronously stores a batch of messages to the message cache -func (c *commonStore) AddMessages(ms []*model.Message) error { +func (c *Cache) AddMessages(ms []*model.Message) error { return c.addMessages(ms) } -func (c *commonStore) addMessages(ms []*model.Message) error { +func (c *Cache) addMessages(ms []*model.Message) error { c.maybeLock() defer c.maybeUnlock() if c.nop { @@ -209,7 +185,8 @@ func (c *commonStore) addMessages(ms []*model.Message) error { return nil } -func (c *commonStore) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { +// Messages returns messages for a topic since the given marker, optionally including scheduled messages +func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { if since.IsNone() { return make([]*model.Message, 0), nil } else if since.IsLatest() { @@ -220,7 +197,7 @@ func (c *commonStore) Messages(topic string, since model.SinceMarker, scheduled return c.messagesSinceTime(topic, since, scheduled) } -func (c *commonStore) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { +func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { var rows *sql.Rows var err error if scheduled { @@ -234,7 +211,7 @@ func (c *commonStore) messagesSinceTime(topic string, since model.SinceMarker, s return readMessages(rows) } -func (c *commonStore) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { +func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { var rows *sql.Rows var err error if scheduled { @@ -248,7 +225,7 @@ func (c *commonStore) messagesSinceID(topic string, since model.SinceMarker, sch return readMessages(rows) } -func (c *commonStore) messagesLatest(topic string) ([]*model.Message, error) { +func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) { rows, err := c.db.Query(c.queries.selectMessagesLatest, topic) if err != nil { return nil, err @@ -256,7 +233,8 @@ func (c *commonStore) messagesLatest(topic string) ([]*model.Message, error) { return readMessages(rows) } -func (c *commonStore) MessagesDue() ([]*model.Message, error) { +// MessagesDue returns all messages that are due for publishing +func (c *Cache) MessagesDue() ([]*model.Message, error) { rows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix()) if err != nil { return nil, err @@ -265,7 +243,7 @@ func (c *commonStore) MessagesDue() ([]*model.Message, error) { } // MessagesExpired returns a list of IDs for messages that have expired (should be deleted) -func (c *commonStore) MessagesExpired() ([]string, error) { +func (c *Cache) MessagesExpired() ([]string, error) { rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix()) if err != nil { return nil, err @@ -285,7 +263,8 @@ func (c *commonStore) MessagesExpired() ([]string, error) { return ids, nil } -func (c *commonStore) Message(id string) (*model.Message, error) { +// Message returns the message with the given ID, or ErrMessageNotFound if not found +func (c *Cache) Message(id string) (*model.Message, error) { rows, err := c.db.Query(c.queries.selectMessagesByID, id) if err != nil { return nil, err @@ -298,21 +277,23 @@ func (c *commonStore) Message(id string) (*model.Message, error) { } // UpdateMessageTime updates the time column for a message by ID. This is only used for testing. -func (c *commonStore) UpdateMessageTime(messageID string, timestamp int64) error { +func (c *Cache) UpdateMessageTime(messageID string, timestamp int64) error { c.maybeLock() defer c.maybeUnlock() _, err := c.db.Exec(c.queries.updateMessageTime, timestamp, messageID) return err } -func (c *commonStore) MarkPublished(m *model.Message) error { +// MarkPublished marks a message as published +func (c *Cache) MarkPublished(m *model.Message) error { c.maybeLock() defer c.maybeUnlock() _, err := c.db.Exec(c.queries.updateMessagePublished, m.ID) return err } -func (c *commonStore) MessagesCount() (int, error) { +// MessagesCount returns the total number of messages in the cache +func (c *Cache) MessagesCount() (int, error) { rows, err := c.db.Query(c.queries.selectMessagesCount) if err != nil { return 0, err @@ -328,7 +309,8 @@ func (c *commonStore) MessagesCount() (int, error) { return count, nil } -func (c *commonStore) Topics() ([]string, error) { +// Topics returns a list of all topics with messages in the cache +func (c *Cache) Topics() ([]string, error) { rows, err := c.db.Query(c.queries.selectTopics) if err != nil { return nil, err @@ -348,7 +330,8 @@ func (c *commonStore) Topics() ([]string, error) { return topics, nil } -func (c *commonStore) DeleteMessages(ids ...string) error { +// DeleteMessages deletes the messages with the given IDs +func (c *Cache) DeleteMessages(ids ...string) error { c.maybeLock() defer c.maybeUnlock() tx, err := c.db.Begin() @@ -366,7 +349,7 @@ func (c *commonStore) DeleteMessages(ids ...string) error { // DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID. // It returns the message IDs of the deleted messages, which can be used to clean up attachment files. -func (c *commonStore) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) { +func (c *Cache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) { c.maybeLock() defer c.maybeUnlock() tx, err := c.db.Begin() @@ -402,7 +385,8 @@ func (c *commonStore) DeleteScheduledBySequenceID(topic, sequenceID string) ([]s return ids, nil } -func (c *commonStore) ExpireMessages(topics ...string) error { +// ExpireMessages marks messages in the given topics as expired +func (c *Cache) ExpireMessages(topics ...string) error { c.maybeLock() defer c.maybeUnlock() tx, err := c.db.Begin() @@ -418,7 +402,8 @@ func (c *commonStore) ExpireMessages(topics ...string) error { return tx.Commit() } -func (c *commonStore) AttachmentsExpired() ([]string, error) { +// AttachmentsExpired returns message IDs with expired attachments that have not been deleted +func (c *Cache) AttachmentsExpired() ([]string, error) { rows, err := c.db.Query(c.queries.selectAttachmentsExpired, time.Now().Unix()) if err != nil { return nil, err @@ -438,7 +423,8 @@ func (c *commonStore) AttachmentsExpired() ([]string, error) { return ids, nil } -func (c *commonStore) MarkAttachmentsDeleted(ids ...string) error { +// MarkAttachmentsDeleted marks the attachments for the given message IDs as deleted +func (c *Cache) MarkAttachmentsDeleted(ids ...string) error { c.maybeLock() defer c.maybeUnlock() tx, err := c.db.Begin() @@ -454,7 +440,8 @@ func (c *commonStore) MarkAttachmentsDeleted(ids ...string) error { return tx.Commit() } -func (c *commonStore) AttachmentBytesUsedBySender(sender string) (int64, error) { +// AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender +func (c *Cache) AttachmentBytesUsedBySender(sender string) (int64, error) { rows, err := c.db.Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix()) if err != nil { return 0, err @@ -462,7 +449,8 @@ func (c *commonStore) AttachmentBytesUsedBySender(sender string) (int64, error) return c.readAttachmentBytesUsed(rows) } -func (c *commonStore) AttachmentBytesUsedByUser(userID string) (int64, error) { +// AttachmentBytesUsedByUser returns the total size of active attachments for the given user +func (c *Cache) AttachmentBytesUsedByUser(userID string) (int64, error) { rows, err := c.db.Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix()) if err != nil { return 0, err @@ -470,7 +458,7 @@ func (c *commonStore) AttachmentBytesUsedByUser(userID string) (int64, error) { return c.readAttachmentBytesUsed(rows) } -func (c *commonStore) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) { +func (c *Cache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) { defer rows.Close() var size int64 if !rows.Next() { @@ -484,14 +472,16 @@ func (c *commonStore) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) { return size, nil } -func (c *commonStore) UpdateStats(messages int64) error { +// UpdateStats updates the total message count statistic +func (c *Cache) UpdateStats(messages int64) error { c.maybeLock() defer c.maybeUnlock() _, err := c.db.Exec(c.queries.updateStats, messages) return err } -func (c *commonStore) Stats() (messages int64, err error) { +// Stats returns the total message count statistic +func (c *Cache) Stats() (messages int64, err error) { rows, err := c.db.Query(c.queries.selectStats) if err != nil { return 0, err @@ -506,11 +496,12 @@ func (c *commonStore) Stats() (messages int64, err error) { return messages, nil } -func (c *commonStore) Close() error { +// Close closes the underlying database connection +func (c *Cache) Close() error { return c.db.Close() } -func (c *commonStore) processMessageBatches() { +func (c *Cache) processMessageBatches() { if c.queue == nil { return } diff --git a/message/store_postgres.go b/message/cache_postgres.go similarity index 97% rename from message/store_postgres.go rename to message/cache_postgres.go index a21b8f62..f5ced3bb 100644 --- a/message/store_postgres.go +++ b/message/cache_postgres.go @@ -75,7 +75,7 @@ const ( postgresUpdateMessageTimesQuery = `UPDATE message SET time = $1 WHERE mid = $2` ) -var pgQueries = storeQueries{ +var pgQueries = queries{ insertMessage: postgresInsertMessageQuery, deleteMessage: postgresDeleteMessageQuery, selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery, @@ -102,9 +102,9 @@ var pgQueries = storeQueries{ } // NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. -func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (Store, error) { +func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) { if err := setupPostgresDB(db); err != nil { return nil, err } - return newCommonStore(db, pgQueries, nil, batchSize, batchTimeout, false), nil + return newCache(db, pgQueries, nil, batchSize, batchTimeout, false), nil } diff --git a/message/store_postgres_schema.go b/message/cache_postgres_schema.go similarity index 100% rename from message/store_postgres_schema.go rename to message/cache_postgres_schema.go diff --git a/message/store_sqlite.go b/message/cache_sqlite.go similarity index 97% rename from message/store_sqlite.go rename to message/cache_sqlite.go index 923f3480..f9d8605e 100644 --- a/message/store_sqlite.go +++ b/message/cache_sqlite.go @@ -78,7 +78,7 @@ const ( sqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?` ) -var sqliteQueries = storeQueries{ +var sqliteQueries = queries{ insertMessage: sqliteInsertMessageQuery, deleteMessage: sqliteDeleteMessageQuery, selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery, @@ -105,7 +105,7 @@ var sqliteQueries = storeQueries{ } // NewSQLiteStore creates a SQLite file-backed cache -func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (Store, error) { +func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*Cache, error) { parentDir := filepath.Dir(filename) if !util.FileExists(parentDir) { return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir) @@ -117,17 +117,17 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration if err := setupSQLite(db, startupQueries, cacheDuration); err != nil { return nil, err } - return newCommonStore(db, sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil + return newCache(db, sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil } // NewMemStore creates an in-memory cache -func NewMemStore() (Store, error) { +func NewMemStore() (*Cache, error) { return NewSQLiteStore(createMemoryFilename(), "", 0, 0, 0, false) } // NewNopStore creates an in-memory cache that discards all messages; // it is always empty and can be used if caching is entirely disabled -func NewNopStore() (Store, error) { +func NewNopStore() (*Cache, error) { return NewSQLiteStore(createMemoryFilename(), "", 0, 0, 0, true) } diff --git a/message/store_sqlite_schema.go b/message/cache_sqlite_schema.go similarity index 100% rename from message/store_sqlite_schema.go rename to message/cache_sqlite_schema.go diff --git a/message/store_sqlite_test.go b/message/cache_sqlite_test.go similarity index 99% rename from message/store_sqlite_test.go rename to message/cache_sqlite_test.go index e018fa28..e69488e6 100644 --- a/message/store_sqlite_test.go +++ b/message/cache_sqlite_test.go @@ -271,7 +271,7 @@ func newSqliteTestStoreFile(t *testing.T) string { return filepath.Join(t.TempDir(), "cache.db") } -func newSqliteTestStoreFromFile(t *testing.T, filename, startupQueries string) message.Store { +func newSqliteTestStoreFromFile(t *testing.T, filename, startupQueries string) *message.Cache { s, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false) require.Nil(t, err) t.Cleanup(func() { s.Close() }) diff --git a/message/store_test.go b/message/cache_test.go similarity index 95% rename from message/store_test.go rename to message/cache_test.go index e53c11ec..eb992381 100644 --- a/message/store_test.go +++ b/message/cache_test.go @@ -15,7 +15,7 @@ import ( "heckel.io/ntfy/v2/model" ) -func newSqliteTestStore(t *testing.T) message.Store { +func newSqliteTestStore(t *testing.T) *message.Cache { filename := filepath.Join(t.TempDir(), "cache.db") s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false) require.Nil(t, err) @@ -23,21 +23,21 @@ func newSqliteTestStore(t *testing.T) message.Store { return s } -func newMemTestStore(t *testing.T) message.Store { +func newMemTestStore(t *testing.T) *message.Cache { s, err := message.NewMemStore() require.Nil(t, err) t.Cleanup(func() { s.Close() }) return s } -func newTestPostgresStore(t *testing.T) message.Store { +func newTestPostgresStore(t *testing.T) *message.Cache { testDB := dbtest.CreateTestPostgres(t) store, err := message.NewPostgresStore(testDB, 0, 0) require.Nil(t, err) return store } -func forEachBackend(t *testing.T, f func(t *testing.T, s message.Store)) { +func forEachBackend(t *testing.T, f func(t *testing.T, s *message.Cache)) { t.Run("sqlite", func(t *testing.T) { f(t, newSqliteTestStore(t)) }) @@ -50,7 +50,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, s message.Store)) { } func TestStore_Messages(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { m1 := model.NewDefaultMessage("mytopic", "my message") m1.Time = 1 @@ -113,7 +113,7 @@ func TestStore_Messages(t *testing.T) { } func TestStore_MessagesLock(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { var wg sync.WaitGroup for i := 0; i < 5000; i++ { wg.Add(1) @@ -127,7 +127,7 @@ func TestStore_MessagesLock(t *testing.T) { } func TestStore_MessagesScheduled(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { m1 := model.NewDefaultMessage("mytopic", "message 1") m2 := model.NewDefaultMessage("mytopic", "message 2") m2.Time = time.Now().Add(time.Hour).Unix() @@ -155,7 +155,7 @@ func TestStore_MessagesScheduled(t *testing.T) { } func TestStore_Topics(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic1", "my example message"))) require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 1"))) require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 2"))) @@ -172,7 +172,7 @@ func TestStore_Topics(t *testing.T) { } func TestStore_MessagesTagsPrioAndTitle(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { m := model.NewDefaultMessage("mytopic", "some message") m.Tags = []string{"tag1", "tag2"} m.Priority = 5 @@ -187,7 +187,7 @@ func TestStore_MessagesTagsPrioAndTitle(t *testing.T) { } func TestStore_MessagesSinceID(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { m1 := model.NewDefaultMessage("mytopic", "message 1") m1.Time = 100 m2 := model.NewDefaultMessage("mytopic", "message 2") @@ -251,7 +251,7 @@ func TestStore_MessagesSinceID(t *testing.T) { } func TestStore_Prune(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { now := time.Now().Unix() m1 := model.NewDefaultMessage("mytopic", "my message") @@ -290,7 +290,7 @@ func TestStore_Prune(t *testing.T) { } func TestStore_Attachments(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired m := model.NewDefaultMessage("mytopic", "flower for you") m.ID = "m1" @@ -369,7 +369,7 @@ func TestStore_Attachments(t *testing.T) { } func TestStore_AttachmentsExpired(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { m := model.NewDefaultMessage("mytopic", "flower for you") m.ID = "m1" m.SequenceID = "m1" @@ -422,7 +422,7 @@ func TestStore_AttachmentsExpired(t *testing.T) { } func TestStore_Sender(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { m1 := model.NewDefaultMessage("mytopic", "mymessage") m1.Sender = netip.MustParseAddr("1.2.3.4") require.Nil(t, s.AddMessage(m1)) @@ -439,7 +439,7 @@ func TestStore_Sender(t *testing.T) { } func TestStore_DeleteScheduledBySequenceID(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Create a scheduled (unpublished) message scheduledMsg := model.NewDefaultMessage("mytopic", "scheduled message") scheduledMsg.ID = "scheduled1" @@ -506,7 +506,7 @@ func TestStore_DeleteScheduledBySequenceID(t *testing.T) { } func TestStore_MessageByID(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Add a message m := model.NewDefaultMessage("mytopic", "some message") m.Title = "some title" @@ -531,7 +531,7 @@ func TestStore_MessageByID(t *testing.T) { } func TestStore_MarkPublished(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Add a scheduled message (future time -> unpublished) m := model.NewDefaultMessage("mytopic", "scheduled message") m.Time = time.Now().Add(time.Hour).Unix() @@ -559,7 +559,7 @@ func TestStore_MarkPublished(t *testing.T) { } func TestStore_ExpireMessages(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Add messages to two topics m1 := model.NewDefaultMessage("topic1", "message 1") m1.Expires = time.Now().Add(time.Hour).Unix() @@ -600,7 +600,7 @@ func TestStore_ExpireMessages(t *testing.T) { } func TestStore_MarkAttachmentsDeleted(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Add a message with an expired attachment (file needs cleanup) m1 := model.NewDefaultMessage("mytopic", "old file") m1.ID = "msg1" @@ -659,7 +659,7 @@ func TestStore_MarkAttachmentsDeleted(t *testing.T) { } func TestStore_Stats(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Initial stats should be zero messages, err := s.Stats() require.Nil(t, err) @@ -680,7 +680,7 @@ func TestStore_Stats(t *testing.T) { } func TestStore_AddMessages(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Batch add multiple messages msgs := []*model.Message{ model.NewDefaultMessage("mytopic", "batch 1"), @@ -711,7 +711,7 @@ func TestStore_AddMessages(t *testing.T) { } func TestStore_MessagesDue(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Add a message scheduled in the past (i.e. it's due now) m1 := model.NewDefaultMessage("mytopic", "due message") m1.Time = time.Now().Add(-time.Second).Unix() @@ -755,7 +755,7 @@ func TestStore_MessagesDue(t *testing.T) { } func TestStore_MessageFieldRoundTrip(t *testing.T) { - forEachBackend(t, func(t *testing.T, s message.Store) { + forEachBackend(t, func(t *testing.T, s *message.Cache) { // Create a message with all fields populated m := model.NewDefaultMessage("mytopic", "hello world") m.SequenceID = "custom_seq_id" diff --git a/server/server.go b/server/server.go index 55caaa53..a64772af 100644 --- a/server/server.go +++ b/server/server.go @@ -62,8 +62,8 @@ type Server struct { messages int64 // Total number of messages (persisted if messageCache enabled) messagesHistory []int64 // Last n values of the messages counter, used to determine rate userManager *user.Manager // Might be nil! - messageCache message.Store // Database that stores the messages - webPush webpush.Store // Database that stores web push subscriptions + messageCache *message.Cache // Database that stores the messages + webPush *webpush.Store // Database that stores web push subscriptions fileCache *fileCache // File system based cache that stores attachments stripe stripeAPI // Stripe API, can be replaced with a mock priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) @@ -191,7 +191,7 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } - var wp webpush.Store + var wp *webpush.Store if conf.WebPushPublicKey != "" { if pool != nil { wp, err = webpush.NewPostgresStore(pool) @@ -277,7 +277,7 @@ func New(conf *Config) (*Server, error) { return s, nil } -func createMessageCache(conf *Config, pool *sql.DB) (message.Store, error) { +func createMessageCache(conf *Config, pool *sql.DB) (*message.Cache, error) { if conf.CacheDuration == 0 { return message.NewNopStore() } else if pool != nil { diff --git a/server/server_test.go b/server/server_test.go index 1f206562..fc85da05 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4121,7 +4121,7 @@ func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) { }) } -func newMemTestCache(t *testing.T) message.Store { +func newMemTestCache(t *testing.T) *message.Cache { c, err := message.NewMemStore() require.Nil(t, err) return c diff --git a/server/visitor.go b/server/visitor.go index 12217be6..6d8fe6d1 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -54,7 +54,7 @@ const ( // visitor represents an API user, and its associated rate.Limiter used for rate limiting type visitor struct { config *Config - messageCache message.Store + messageCache *message.Cache userManager *user.Manager // May be nil ip netip.Addr // Visitor IP address user *user.User // Only set if authenticated user, otherwise nil @@ -115,7 +115,7 @@ const ( visitorLimitBasisTier = visitorLimitBasis("tier") ) -func newVisitor(conf *Config, messageCache message.Store, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { +func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { var messages, emails, calls int64 if user != nil { messages = user.Stats.Messages diff --git a/webpush/store.go b/webpush/store.go index 4fc5d77f..20ff19cc 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -21,21 +21,14 @@ var ( ErrWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty") ) -// Store is the interface for a web push subscription store. -type Store interface { - UpsertSubscription(endpoint, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error - SubscriptionsForTopic(topic string) ([]*Subscription, error) - SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) - MarkExpiryWarningSent(subscriptions []*Subscription) error - RemoveSubscriptionsByEndpoint(endpoint string) error - RemoveSubscriptionsByUserID(userID string) error - RemoveExpiredSubscriptions(expireAfter time.Duration) error - SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error - Close() error +// Store holds the database connection and queries for web push subscriptions. +type Store struct { + db *sql.DB + queries queries } -// storeQueries holds the database-specific SQL queries. -type storeQueries struct { +// queries holds the database-specific SQL queries. +type queries struct { selectSubscriptionIDByEndpoint string selectSubscriptionCountBySubscriberIP string selectSubscriptionsForTopic string @@ -51,14 +44,8 @@ type storeQueries struct { deleteSubscriptionTopicWithoutSubscription string } -// commonStore implements store operations that are identical across database backends. -type commonStore struct { - db *sql.DB - queries storeQueries -} - // UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. -func (s *commonStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { +func (s *Store) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { tx, err := s.db.Begin() if err != nil { return err @@ -97,7 +84,7 @@ func (s *commonStore) UpsertSubscription(endpoint string, auth, p256dh, userID s } // SubscriptionsForTopic returns all subscriptions for the given topic. -func (s *commonStore) SubscriptionsForTopic(topic string) ([]*Subscription, error) { +func (s *Store) SubscriptionsForTopic(topic string) ([]*Subscription, error) { rows, err := s.db.Query(s.queries.selectSubscriptionsForTopic, topic) if err != nil { return nil, err @@ -107,7 +94,7 @@ func (s *commonStore) SubscriptionsForTopic(topic string) ([]*Subscription, erro } // SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period. -func (s *commonStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) { +func (s *Store) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) { rows, err := s.db.Query(s.queries.selectSubscriptionsExpiringSoon, time.Now().Add(-warnAfter).Unix()) if err != nil { return nil, err @@ -117,7 +104,7 @@ func (s *commonStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscri } // MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon. -func (s *commonStore) MarkExpiryWarningSent(subscriptions []*Subscription) error { +func (s *Store) MarkExpiryWarningSent(subscriptions []*Subscription) error { tx, err := s.db.Begin() if err != nil { return err @@ -132,13 +119,13 @@ func (s *commonStore) MarkExpiryWarningSent(subscriptions []*Subscription) error } // RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint. -func (s *commonStore) RemoveSubscriptionsByEndpoint(endpoint string) error { +func (s *Store) RemoveSubscriptionsByEndpoint(endpoint string) error { _, err := s.db.Exec(s.queries.deleteSubscriptionByEndpoint, endpoint) return err } // RemoveSubscriptionsByUserID removes all subscriptions for the given user ID. -func (s *commonStore) RemoveSubscriptionsByUserID(userID string) error { +func (s *Store) RemoveSubscriptionsByUserID(userID string) error { if userID == "" { return ErrWebPushUserIDCannotBeEmpty } @@ -147,7 +134,7 @@ func (s *commonStore) RemoveSubscriptionsByUserID(userID string) error { } // RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period. -func (s *commonStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error { +func (s *Store) RemoveExpiredSubscriptions(expireAfter time.Duration) error { _, err := s.db.Exec(s.queries.deleteSubscriptionByAge, time.Now().Add(-expireAfter).Unix()) if err != nil { return err @@ -158,13 +145,13 @@ func (s *commonStore) RemoveExpiredSubscriptions(expireAfter time.Duration) erro // SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is // exported for testing purposes. -func (s *commonStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { +func (s *Store) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error { _, err := s.db.Exec(s.queries.updateSubscriptionUpdatedAt, updatedAt, endpoint) return err } // Close closes the underlying database connection. -func (s *commonStore) Close() error { +func (s *Store) Close() error { return s.db.Close() } diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index 5dd72e70..090a93c4 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -71,13 +71,13 @@ const ( ) // NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool. -func NewPostgresStore(db *sql.DB) (Store, error) { +func NewPostgresStore(db *sql.DB) (*Store, error) { if err := setupPostgresDB(db); err != nil { return nil, err } - return &commonStore{ + return &Store{ db: db, - queries: storeQueries{ + queries: queries{ selectSubscriptionIDByEndpoint: postgresSelectSubscriptionIDByEndpointQuery, selectSubscriptionCountBySubscriberIP: postgresSelectSubscriptionCountBySubscriberIPQuery, selectSubscriptionsForTopic: postgresSelectSubscriptionsForTopicQuery, diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index a00c2e61..867891ec 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -76,7 +76,7 @@ const ( ) // NewSQLiteStore creates a new SQLite-backed web push store. -func NewSQLiteStore(filename, startupQueries string) (Store, error) { +func NewSQLiteStore(filename, startupQueries string) (*Store, error) { db, err := sql.Open("sqlite3", filename) if err != nil { return nil, err @@ -87,9 +87,9 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) { if err := runSQLiteStartupQueries(db, startupQueries); err != nil { return nil, err } - return &commonStore{ + return &Store{ db: db, - queries: storeQueries{ + queries: queries{ selectSubscriptionIDByEndpoint: sqliteSelectWebPushSubscriptionIDByEndpointQuery, selectSubscriptionCountBySubscriberIP: sqliteSelectWebPushSubscriptionCountBySubscriberIPQuery, selectSubscriptionsForTopic: sqliteSelectWebPushSubscriptionsForTopicQuery, diff --git a/webpush/store_test.go b/webpush/store_test.go index bb27bf5f..348f9998 100644 --- a/webpush/store_test.go +++ b/webpush/store_test.go @@ -14,7 +14,7 @@ import ( const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" -func forEachBackend(t *testing.T, f func(t *testing.T, store webpush.Store)) { +func forEachBackend(t *testing.T, f func(t *testing.T, store *webpush.Store)) { t.Run("sqlite", func(t *testing.T) { store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") require.Nil(t, err) @@ -30,7 +30,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, store webpush.Store)) { } func TestStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"})) subs, err := store.SubscriptionsForTopic("test-topic") @@ -49,7 +49,7 @@ func TestStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) { } func TestStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert 10 subscriptions with the same IP address for i := 0; i < 10; i++ { endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i) @@ -68,7 +68,7 @@ func TestStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) { } func TestStoreUpsertSubscriptionUpdateTopics(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert subscription with two topics, and another with one topic require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"})) @@ -99,7 +99,7 @@ func TestStoreUpsertSubscriptionUpdateTopics(t *testing.T) { } func TestStoreUpsertSubscriptionUpdateFields(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert a subscription require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) @@ -124,7 +124,7 @@ func TestStoreUpsertSubscriptionUpdateFields(t *testing.T) { } func TestStoreRemoveByUserIDMultiple(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert two subscriptions for u_1234 and one for u_5678 require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"})) @@ -147,7 +147,7 @@ func TestStoreRemoveByUserIDMultiple(t *testing.T) { } func TestStoreRemoveByEndpoint(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) subs, err := store.SubscriptionsForTopic("topic1") @@ -163,7 +163,7 @@ func TestStoreRemoveByEndpoint(t *testing.T) { } func TestStoreRemoveByUserID(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) subs, err := store.SubscriptionsForTopic("topic1") @@ -179,13 +179,13 @@ func TestStoreRemoveByUserID(t *testing.T) { } func TestStoreRemoveByUserIDEmpty(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID("")) }) } func TestStoreExpiryWarningSent(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) @@ -209,7 +209,7 @@ func TestStoreExpiryWarningSent(t *testing.T) { } func TestStoreExpiring(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) subs, err := store.SubscriptionsForTopic("topic1") @@ -231,7 +231,7 @@ func TestStoreExpiring(t *testing.T) { } func TestStoreRemoveExpired(t *testing.T) { - forEachBackend(t, func(t *testing.T, store webpush.Store) { + forEachBackend(t, func(t *testing.T, store *webpush.Store) { // Insert subscription with two topics require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"})) subs, err := store.SubscriptionsForTopic("topic1") From 10a6939d8ed31a36605eed58743fa4430d8c5a77 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Mar 2026 13:47:42 -0500 Subject: [PATCH 251/548] Rename queries for consistency --- message/cache_postgres.go | 8 ++-- message/cache_sqlite_schema.go | 4 +- webpush/store_sqlite.go | 70 +++++++++++++++++----------------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/message/cache_postgres.go b/message/cache_postgres.go index f5ced3bb..0a57d18e 100644 --- a/message/cache_postgres.go +++ b/message/cache_postgres.go @@ -70,9 +70,9 @@ const ( postgresSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2` postgresSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2` - postgresSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` - postgresUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` - postgresUpdateMessageTimesQuery = `UPDATE message SET time = $1 WHERE mid = $2` + postgresSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` + postgresUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` + postgresUpdateMessageTimeQuery = `UPDATE message SET time = $1 WHERE mid = $2` ) var pgQueries = queries{ @@ -98,7 +98,7 @@ var pgQueries = queries{ selectAttachmentsSizeByUserID: postgresSelectAttachmentsSizeByUserIDQuery, selectStats: postgresSelectStatsQuery, updateStats: postgresUpdateStatsQuery, - updateMessageTime: postgresUpdateMessageTimesQuery, + updateMessageTime: postgresUpdateMessageTimeQuery, } // NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. diff --git a/message/cache_sqlite_schema.go b/message/cache_sqlite_schema.go index 8adc80f4..00a26c09 100644 --- a/message/cache_sqlite_schema.go +++ b/message/cache_sqlite_schema.go @@ -10,7 +10,7 @@ import ( // Initial SQLite schema const ( - sqliteCreateMessagesTableQuery = ` + sqliteCreateTablesQuery = ` BEGIN; CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -259,7 +259,7 @@ func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) } func setupNewSQLite(db *sql.DB) error { - if _, err := db.Exec(sqliteCreateMessagesTableQuery); err != nil { + if _, err := db.Exec(sqliteCreateTablesQuery); err != nil { return err } if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 867891ec..d41e1c8d 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -8,7 +8,7 @@ import ( ) const ( - sqliteCreateWebPushSubscriptionsTableQuery = ` + sqliteCreateTablesQuery = ` CREATE TABLE IF NOT EXISTS subscription ( id TEXT PRIMARY KEY, endpoint TEXT NOT NULL, @@ -37,42 +37,42 @@ const ( PRAGMA foreign_keys = ON; ` - sqliteSelectWebPushSubscriptionIDByEndpointQuery = `SELECT id FROM subscription WHERE endpoint = ?` - sqliteSelectWebPushSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?` - sqliteSelectWebPushSubscriptionsForTopicQuery = ` + sqliteSelectSubscriptionIDByEndpointQuery = `SELECT id FROM subscription WHERE endpoint = ?` + sqliteSelectSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?` + sqliteSelectSubscriptionsForTopicQuery = ` SELECT id, endpoint, key_auth, key_p256dh, user_id FROM subscription_topic st JOIN subscription s ON s.id = st.subscription_id WHERE st.topic = ? ORDER BY endpoint ` - sqliteSelectWebPushSubscriptionsExpiringSoonQuery = ` + sqliteSelectSubscriptionsExpiringSoonQuery = ` SELECT id, endpoint, key_auth, key_p256dh, user_id FROM subscription WHERE warned_at = 0 AND updated_at <= ? ` - sqliteInsertWebPushSubscriptionQuery = ` + sqliteUpsertSubscriptionQuery = ` INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (endpoint) DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at ` - sqliteUpdateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?` - sqliteUpdateWebPushSubscriptionUpdatedAtQuery = `UPDATE subscription SET updated_at = ? WHERE endpoint = ?` - sqliteDeleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?` - sqliteDeleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?` - sqliteDeleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan! + sqliteUpdateSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?` + sqliteUpdateSubscriptionUpdatedAtQuery = `UPDATE subscription SET updated_at = ? WHERE endpoint = ?` + sqliteDeleteSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?` + sqliteDeleteSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?` + sqliteDeleteSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan! - sqliteInsertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)` - sqliteDeleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?` - sqliteDeleteWebPushSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)` + sqliteInsertSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)` + sqliteDeleteSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?` + sqliteDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)` ) // SQLite schema management queries const ( - sqliteCurrentWebPushSchemaVersion = 1 - sqliteInsertWebPushSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` - sqliteSelectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` + sqliteCurrentSchemaVersion = 1 + sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` + sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` ) // NewSQLiteStore creates a new SQLite-backed web push store. @@ -90,31 +90,31 @@ func NewSQLiteStore(filename, startupQueries string) (*Store, error) { return &Store{ db: db, queries: queries{ - selectSubscriptionIDByEndpoint: sqliteSelectWebPushSubscriptionIDByEndpointQuery, - selectSubscriptionCountBySubscriberIP: sqliteSelectWebPushSubscriptionCountBySubscriberIPQuery, - selectSubscriptionsForTopic: sqliteSelectWebPushSubscriptionsForTopicQuery, - selectSubscriptionsExpiringSoon: sqliteSelectWebPushSubscriptionsExpiringSoonQuery, - upsertSubscription: sqliteInsertWebPushSubscriptionQuery, - updateSubscriptionWarningSent: sqliteUpdateWebPushSubscriptionWarningSentQuery, - updateSubscriptionUpdatedAt: sqliteUpdateWebPushSubscriptionUpdatedAtQuery, - deleteSubscriptionByEndpoint: sqliteDeleteWebPushSubscriptionByEndpointQuery, - deleteSubscriptionByUserID: sqliteDeleteWebPushSubscriptionByUserIDQuery, - deleteSubscriptionByAge: sqliteDeleteWebPushSubscriptionByAgeQuery, - insertSubscriptionTopic: sqliteInsertWebPushSubscriptionTopicQuery, - deleteSubscriptionTopicAll: sqliteDeleteWebPushSubscriptionTopicAllQuery, - deleteSubscriptionTopicWithoutSubscription: sqliteDeleteWebPushSubscriptionTopicWithoutSubscriptionQuery, + selectSubscriptionIDByEndpoint: sqliteSelectSubscriptionIDByEndpointQuery, + selectSubscriptionCountBySubscriberIP: sqliteSelectSubscriptionCountBySubscriberIPQuery, + selectSubscriptionsForTopic: sqliteSelectSubscriptionsForTopicQuery, + selectSubscriptionsExpiringSoon: sqliteSelectSubscriptionsExpiringSoonQuery, + upsertSubscription: sqliteUpsertSubscriptionQuery, + updateSubscriptionWarningSent: sqliteUpdateSubscriptionWarningSentQuery, + updateSubscriptionUpdatedAt: sqliteUpdateSubscriptionUpdatedAtQuery, + deleteSubscriptionByEndpoint: sqliteDeleteSubscriptionByEndpointQuery, + deleteSubscriptionByUserID: sqliteDeleteSubscriptionByUserIDQuery, + deleteSubscriptionByAge: sqliteDeleteSubscriptionByAgeQuery, + insertSubscriptionTopic: sqliteInsertSubscriptionTopicQuery, + deleteSubscriptionTopicAll: sqliteDeleteSubscriptionTopicAllQuery, + deleteSubscriptionTopicWithoutSubscription: sqliteDeleteSubscriptionTopicWithoutSubscriptionQuery, }, }, nil } func setupSQLite(db *sql.DB) error { var schemaVersion int - err := db.QueryRow(sqliteSelectWebPushSchemaVersionQuery).Scan(&schemaVersion) + err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { return setupNewSQLite(db) } - if schemaVersion > sqliteCurrentWebPushSchemaVersion { - return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentWebPushSchemaVersion) + if schemaVersion > sqliteCurrentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion) } return nil } @@ -125,10 +125,10 @@ func setupNewSQLite(db *sql.DB) error { return err } defer tx.Rollback() - if _, err := tx.Exec(sqliteCreateWebPushSubscriptionsTableQuery); err != nil { + if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil { return err } - if _, err := tx.Exec(sqliteInsertWebPushSchemaVersionQuery, sqliteCurrentWebPushSchemaVersion); err != nil { + if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil { return err } return tx.Commit() From 11c79a63699cc5d2634b101b7b244aa89583cd77 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Mar 2026 14:24:06 -0500 Subject: [PATCH 252/548] Refine again --- message/cache_postgres.go | 2 +- message/cache_postgres_schema.go | 2 +- user/manager.go | 22 +++-- user/manager_postgres.go | 136 ++++++++++++++----------------- user/manager_sqlite.go | 124 +++++++++++++--------------- user/manager_test.go | 20 ----- 6 files changed, 133 insertions(+), 173 deletions(-) diff --git a/message/cache_postgres.go b/message/cache_postgres.go index 0a57d18e..ce0551c9 100644 --- a/message/cache_postgres.go +++ b/message/cache_postgres.go @@ -103,7 +103,7 @@ var pgQueries = queries{ // NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) { - if err := setupPostgresDB(db); err != nil { + if err := setupPostgres(db); err != nil { return nil, err } return newCache(db, pgQueries, nil, batchSize, batchTimeout, false), nil diff --git a/message/cache_postgres_schema.go b/message/cache_postgres_schema.go index a647cdf9..4b5730a0 100644 --- a/message/cache_postgres_schema.go +++ b/message/cache_postgres_schema.go @@ -60,7 +60,7 @@ const ( postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'` ) -func setupPostgresDB(db *sql.DB) error { +func setupPostgres(db *sql.DB) error { var schemaVersion int err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { diff --git a/user/manager.go b/user/manager.go index a8e8dba3..a1412be3 100644 --- a/user/manager.go +++ b/user/manager.go @@ -57,19 +57,25 @@ type Manager struct { var _ Auther = (*Manager)(nil) -// initManager sets defaults and runs startup tasks common to all backends -func initManager(manager *Manager) error { - if manager.config.BcryptCost <= 0 { - manager.config.BcryptCost = DefaultUserPasswordBcryptCost +func newManager(db *sql.DB, queries queries, config *Config) (*Manager, error) { + if config.BcryptCost <= 0 { + config.BcryptCost = DefaultUserPasswordBcryptCost } - if manager.config.QueueWriterInterval.Seconds() <= 0 { - manager.config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval + if config.QueueWriterInterval.Seconds() <= 0 { + config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval + } + manager := &Manager{ + config: config, + db: db, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), + queries: queries, } if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil { - return err + return nil, err } go manager.asyncQueueWriter(manager.config.QueueWriterInterval) - return nil + return manager, nil } // Authenticate checks username and password and returns a User if correct, and the user has not been diff --git a/user/manager_postgres.go b/user/manager_postgres.go index b7ef2d08..bc8e3852 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -204,85 +204,67 @@ const ( ) // NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool. +var postgresQueries = queries{ + selectUserByID: postgresSelectUserByIDQuery, + selectUserByName: postgresSelectUserByNameQuery, + selectUserByToken: postgresSelectUserByTokenQuery, + selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery, + selectUsernames: postgresSelectUsernamesQuery, + selectUserCount: postgresSelectUserCountQuery, + selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery, + insertUser: postgresInsertUserQuery, + updateUserPass: postgresUpdateUserPassQuery, + updateUserRole: postgresUpdateUserRoleQuery, + updateUserProvisioned: postgresUpdateUserProvisionedQuery, + updateUserPrefs: postgresUpdateUserPrefsQuery, + updateUserStats: postgresUpdateUserStatsQuery, + updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery, + updateUserTier: postgresUpdateUserTierQuery, + updateUserDeleted: postgresUpdateUserDeletedQuery, + deleteUser: postgresDeleteUserQuery, + deleteUserTier: postgresDeleteUserTierQuery, + deleteUsersMarked: postgresDeleteUsersMarkedQuery, + selectTopicPerms: postgresSelectTopicPermsQuery, + selectUserAllAccess: postgresSelectUserAllAccessQuery, + selectUserAccess: postgresSelectUserAccessQuery, + selectUserReservations: postgresSelectUserReservationsQuery, + selectUserReservationsCount: postgresSelectUserReservationsCountQuery, + selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery, + selectUserHasReservation: postgresSelectUserHasReservationQuery, + selectOtherAccessCount: postgresSelectOtherAccessCountQuery, + upsertUserAccess: postgresUpsertUserAccessQuery, + deleteUserAccess: postgresDeleteUserAccessQuery, + deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery, + deleteTopicAccess: postgresDeleteTopicAccessQuery, + deleteAllAccess: postgresDeleteAllAccessQuery, + selectToken: postgresSelectTokenQuery, + selectTokens: postgresSelectTokensQuery, + selectTokenCount: postgresSelectTokenCountQuery, + selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, + upsertToken: postgresUpsertTokenQuery, + updateToken: postgresUpdateTokenQuery, + updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, + deleteToken: postgresDeleteTokenQuery, + deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, + deleteAllToken: postgresDeleteAllTokenQuery, + deleteExpiredTokens: postgresDeleteExpiredTokensQuery, + deleteExcessTokens: postgresDeleteExcessTokensQuery, + insertTier: postgresInsertTierQuery, + selectTiers: postgresSelectTiersQuery, + selectTierByCode: postgresSelectTierByCodeQuery, + selectTierByPriceID: postgresSelectTierByPriceIDQuery, + updateTier: postgresUpdateTierQuery, + deleteTier: postgresDeleteTierQuery, + selectPhoneNumbers: postgresSelectPhoneNumbersQuery, + insertPhoneNumber: postgresInsertPhoneNumberQuery, + deletePhoneNumber: postgresDeletePhoneNumberQuery, + updateBilling: postgresUpdateBillingQuery, +} + +// NewPostgresManager creates a new Manager backed by a PostgreSQL database func NewPostgresManager(db *sql.DB, config *Config) (*Manager, error) { if err := setupPostgres(db); err != nil { return nil, err } - manager := &Manager{ - config: config, - db: db, - statsQueue: make(map[string]*Stats), - tokenQueue: make(map[string]*TokenUpdate), - queries: queries{ - // User queries - selectUserByID: postgresSelectUserByIDQuery, - selectUserByName: postgresSelectUserByNameQuery, - selectUserByToken: postgresSelectUserByTokenQuery, - selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery, - selectUsernames: postgresSelectUsernamesQuery, - selectUserCount: postgresSelectUserCountQuery, - selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery, - insertUser: postgresInsertUserQuery, - updateUserPass: postgresUpdateUserPassQuery, - updateUserRole: postgresUpdateUserRoleQuery, - updateUserProvisioned: postgresUpdateUserProvisionedQuery, - updateUserPrefs: postgresUpdateUserPrefsQuery, - updateUserStats: postgresUpdateUserStatsQuery, - updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery, - updateUserTier: postgresUpdateUserTierQuery, - updateUserDeleted: postgresUpdateUserDeletedQuery, - deleteUser: postgresDeleteUserQuery, - deleteUserTier: postgresDeleteUserTierQuery, - deleteUsersMarked: postgresDeleteUsersMarkedQuery, - - // Access queries - selectTopicPerms: postgresSelectTopicPermsQuery, - selectUserAllAccess: postgresSelectUserAllAccessQuery, - selectUserAccess: postgresSelectUserAccessQuery, - selectUserReservations: postgresSelectUserReservationsQuery, - selectUserReservationsCount: postgresSelectUserReservationsCountQuery, - selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery, - selectUserHasReservation: postgresSelectUserHasReservationQuery, - selectOtherAccessCount: postgresSelectOtherAccessCountQuery, - upsertUserAccess: postgresUpsertUserAccessQuery, - deleteUserAccess: postgresDeleteUserAccessQuery, - deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery, - deleteTopicAccess: postgresDeleteTopicAccessQuery, - deleteAllAccess: postgresDeleteAllAccessQuery, - - // Token queries - selectToken: postgresSelectTokenQuery, - selectTokens: postgresSelectTokensQuery, - selectTokenCount: postgresSelectTokenCountQuery, - selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, - upsertToken: postgresUpsertTokenQuery, - updateToken: postgresUpdateTokenQuery, - updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, - deleteToken: postgresDeleteTokenQuery, - deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, - deleteAllToken: postgresDeleteAllTokenQuery, - deleteExpiredTokens: postgresDeleteExpiredTokensQuery, - deleteExcessTokens: postgresDeleteExcessTokensQuery, - - // Tier queries - insertTier: postgresInsertTierQuery, - selectTiers: postgresSelectTiersQuery, - selectTierByCode: postgresSelectTierByCodeQuery, - selectTierByPriceID: postgresSelectTierByPriceIDQuery, - updateTier: postgresUpdateTierQuery, - deleteTier: postgresDeleteTierQuery, - - // Phone queries - selectPhoneNumbers: postgresSelectPhoneNumbersQuery, - insertPhoneNumber: postgresInsertPhoneNumberQuery, - deletePhoneNumber: postgresDeletePhoneNumberQuery, - - // Billing queries - updateBilling: postgresUpdateBillingQuery, - }, - } - if err := initManager(manager); err != nil { - return nil, err - } - return manager, nil + return newManager(db, postgresQueries, config) } diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 2d33087f..e09c682c 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -201,6 +201,63 @@ const ( ` ) +var sqliteQueries = queries{ + selectUserByID: sqliteSelectUserByIDQuery, + selectUserByName: sqliteSelectUserByNameQuery, + selectUserByToken: sqliteSelectUserByTokenQuery, + selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery, + selectUsernames: sqliteSelectUsernamesQuery, + selectUserCount: sqliteSelectUserCountQuery, + selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery, + insertUser: sqliteInsertUserQuery, + updateUserPass: sqliteUpdateUserPassQuery, + updateUserRole: sqliteUpdateUserRoleQuery, + updateUserProvisioned: sqliteUpdateUserProvisionedQuery, + updateUserPrefs: sqliteUpdateUserPrefsQuery, + updateUserStats: sqliteUpdateUserStatsQuery, + updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery, + updateUserTier: sqliteUpdateUserTierQuery, + updateUserDeleted: sqliteUpdateUserDeletedQuery, + deleteUser: sqliteDeleteUserQuery, + deleteUserTier: sqliteDeleteUserTierQuery, + deleteUsersMarked: sqliteDeleteUsersMarkedQuery, + selectTopicPerms: sqliteSelectTopicPermsQuery, + selectUserAllAccess: sqliteSelectUserAllAccessQuery, + selectUserAccess: sqliteSelectUserAccessQuery, + selectUserReservations: sqliteSelectUserReservationsQuery, + selectUserReservationsCount: sqliteSelectUserReservationsCountQuery, + selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery, + selectUserHasReservation: sqliteSelectUserHasReservationQuery, + selectOtherAccessCount: sqliteSelectOtherAccessCountQuery, + upsertUserAccess: sqliteUpsertUserAccessQuery, + deleteUserAccess: sqliteDeleteUserAccessQuery, + deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery, + deleteTopicAccess: sqliteDeleteTopicAccessQuery, + deleteAllAccess: sqliteDeleteAllAccessQuery, + selectToken: sqliteSelectTokenQuery, + selectTokens: sqliteSelectTokensQuery, + selectTokenCount: sqliteSelectTokenCountQuery, + selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, + upsertToken: sqliteUpsertTokenQuery, + updateToken: sqliteUpdateTokenQuery, + updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, + deleteToken: sqliteDeleteTokenQuery, + deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, + deleteAllToken: sqliteDeleteAllTokenQuery, + deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, + deleteExcessTokens: sqliteDeleteExcessTokensQuery, + insertTier: sqliteInsertTierQuery, + selectTiers: sqliteSelectTiersQuery, + selectTierByCode: sqliteSelectTierByCodeQuery, + selectTierByPriceID: sqliteSelectTierByPriceIDQuery, + updateTier: sqliteUpdateTierQuery, + deleteTier: sqliteDeleteTierQuery, + selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, + insertPhoneNumber: sqliteInsertPhoneNumberQuery, + deletePhoneNumber: sqliteDeletePhoneNumberQuery, + updateBilling: sqliteUpdateBillingQuery, +} + // NewSQLiteManager creates a new Manager backed by a SQLite database func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager, error) { parentDir := filepath.Dir(filename) @@ -217,70 +274,5 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager if err := runSQLiteStartupQueries(db, startupQueries); err != nil { return nil, err } - manager := &Manager{ - config: config, - db: db, - statsQueue: make(map[string]*Stats), - tokenQueue: make(map[string]*TokenUpdate), - queries: queries{ - selectUserByID: sqliteSelectUserByIDQuery, - selectUserByName: sqliteSelectUserByNameQuery, - selectUserByToken: sqliteSelectUserByTokenQuery, - selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery, - selectUsernames: sqliteSelectUsernamesQuery, - selectUserCount: sqliteSelectUserCountQuery, - selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery, - insertUser: sqliteInsertUserQuery, - updateUserPass: sqliteUpdateUserPassQuery, - updateUserRole: sqliteUpdateUserRoleQuery, - updateUserProvisioned: sqliteUpdateUserProvisionedQuery, - updateUserPrefs: sqliteUpdateUserPrefsQuery, - updateUserStats: sqliteUpdateUserStatsQuery, - updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery, - updateUserTier: sqliteUpdateUserTierQuery, - updateUserDeleted: sqliteUpdateUserDeletedQuery, - deleteUser: sqliteDeleteUserQuery, - deleteUserTier: sqliteDeleteUserTierQuery, - deleteUsersMarked: sqliteDeleteUsersMarkedQuery, - selectTopicPerms: sqliteSelectTopicPermsQuery, - selectUserAllAccess: sqliteSelectUserAllAccessQuery, - selectUserAccess: sqliteSelectUserAccessQuery, - selectUserReservations: sqliteSelectUserReservationsQuery, - selectUserReservationsCount: sqliteSelectUserReservationsCountQuery, - selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery, - selectUserHasReservation: sqliteSelectUserHasReservationQuery, - selectOtherAccessCount: sqliteSelectOtherAccessCountQuery, - upsertUserAccess: sqliteUpsertUserAccessQuery, - deleteUserAccess: sqliteDeleteUserAccessQuery, - deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery, - deleteTopicAccess: sqliteDeleteTopicAccessQuery, - deleteAllAccess: sqliteDeleteAllAccessQuery, - selectToken: sqliteSelectTokenQuery, - selectTokens: sqliteSelectTokensQuery, - selectTokenCount: sqliteSelectTokenCountQuery, - selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, - upsertToken: sqliteUpsertTokenQuery, - updateToken: sqliteUpdateTokenQuery, - updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, - deleteToken: sqliteDeleteTokenQuery, - deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, - deleteAllToken: sqliteDeleteAllTokenQuery, - deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, - deleteExcessTokens: sqliteDeleteExcessTokensQuery, - insertTier: sqliteInsertTierQuery, - selectTiers: sqliteSelectTiersQuery, - selectTierByCode: sqliteSelectTierByCodeQuery, - selectTierByPriceID: sqliteSelectTierByPriceIDQuery, - updateTier: sqliteUpdateTierQuery, - deleteTier: sqliteDeleteTierQuery, - selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, - insertPhoneNumber: sqliteInsertPhoneNumberQuery, - deletePhoneNumber: sqliteDeletePhoneNumberQuery, - updateBilling: sqliteUpdateBillingQuery, - }, - } - if err := initManager(manager); err != nil { - return nil, err - } - return manager, nil + return newManager(db, sqliteQueries, config) } diff --git a/user/manager_test.go b/user/manager_test.go index f566e082..fd1e59fc 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1958,26 +1958,6 @@ func TestStoreTokenRemoveExpired(t *testing.T) { }) } -func TestStoreTokenCreatePrunesExcess(t *testing.T) { - forEachStoreBackend(t, func(t *testing.T, manager *Manager) { - require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) - u, err := manager.User("phil") - require.Nil(t, err) - - // Create several tokens - var tokenValues []string - for i := 0; i < 3; i++ { - tk, err := manager.CreateToken(u.ID, "label", time.Now().Add(time.Duration(i+1)*time.Hour), netip.MustParseAddr("1.2.3.4"), false) - require.Nil(t, err) - tokenValues = append(tokenValues, tk.Value) - } - - tokens, err := manager.Tokens(u.ID) - require.Nil(t, err) - require.True(t, len(tokens) >= 3) - }) -} - func TestStoreTokenUpdateLastAccess(t *testing.T) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) { require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) From 0e6b467cf08d73c598780bffe6483dc4272a222c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 2 Mar 2026 12:58:01 -0500 Subject: [PATCH 253/548] Manual fixes --- message/cache_postgres.go | 4 ++-- server/server_test.go | 5 ++--- webpush/store_postgres.go | 8 ++++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/message/cache_postgres.go b/message/cache_postgres.go index ce0551c9..0146f409 100644 --- a/message/cache_postgres.go +++ b/message/cache_postgres.go @@ -75,7 +75,7 @@ const ( postgresUpdateMessageTimeQuery = `UPDATE message SET time = $1 WHERE mid = $2` ) -var pgQueries = queries{ +var postgresQueries = queries{ insertMessage: postgresInsertMessageQuery, deleteMessage: postgresDeleteMessageQuery, selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery, @@ -106,5 +106,5 @@ func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (*C if err := setupPostgres(db); err != nil { return nil, err } - return newCache(db, pgQueries, nil, batchSize, batchTimeout, false), nil + return newCache(db, postgresQueries, nil, batchSize, batchTimeout, false), nil } diff --git a/server/server_test.go b/server/server_test.go index fc85da05..24bf6cac 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2999,11 +2999,10 @@ func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) { func TestServer_MessageHistoryMaxSize(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { - t.Parallel() + // Do not run in parallel, we've seen race conditions with SQLite closing s := newTestServer(t, newTestConfig(t, databaseURL)) for i := 0; i < 20; i++ { - s.messages = int64(i) - s.execManager() + s.updateAndWriteStats(int64(i)) } require.Equal(t, []int64{10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, s.messagesHistory) }) diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index 090a93c4..d9c61470 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -72,7 +72,7 @@ const ( // NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool. func NewPostgresStore(db *sql.DB) (*Store, error) { - if err := setupPostgresDB(db); err != nil { + if err := setupPostgres(db); err != nil { return nil, err } return &Store{ @@ -95,11 +95,11 @@ func NewPostgresStore(db *sql.DB) (*Store, error) { }, nil } -func setupPostgresDB(db *sql.DB) error { +func setupPostgres(db *sql.DB) error { var schemaVersion int err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { - return setupNewPostgresDB(db) + return setupNewPostgres(db) } if schemaVersion > pgCurrentSchemaVersion { return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion) @@ -107,7 +107,7 @@ func setupPostgresDB(db *sql.DB) error { return nil } -func setupNewPostgresDB(db *sql.DB) error { +func setupNewPostgres(db *sql.DB) error { tx, err := db.Begin() if err != nil { return err From 31f02340986562879a2572a1a82a3b8f665e4efd Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 2 Mar 2026 13:28:37 -0500 Subject: [PATCH 254/548] Remove goroutine --- server/server.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/server/server.go b/server/server.go index a64772af..2c09b56a 100644 --- a/server/server.go +++ b/server/server.go @@ -2327,9 +2327,7 @@ func (s *Server) updateAndWriteStats(messagesCount int64) { s.messagesHistory = s.messagesHistory[1:] } s.mu.Unlock() - go func() { - if err := s.messageCache.UpdateStats(messagesCount); err != nil { - log.Tag(tagManager).Err(err).Warn("Cannot write messages stats") - } - }() + if err := s.messageCache.UpdateStats(messagesCount); err != nil { + log.Tag(tagManager).Err(err).Warn("Cannot write messages stats") + } } From ea4739f79ba0a40fba5ef9d5188cfab3e9a02e9c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 2 Mar 2026 19:45:35 -0500 Subject: [PATCH 255/548] Extract ExecTx --- db/db.go | 33 +++++++++ message/cache.go | 102 +++++++++++--------------- message/cache_postgres_schema.go | 35 ++++----- message/cache_sqlite_schema.go | 122 ++++++++++++++----------------- user/manager.go | 27 +++---- user/util.go | 32 -------- webpush/store.go | 81 ++++++++++---------- webpush/store_postgres.go | 25 +++---- webpush/store_sqlite.go | 25 +++---- 9 files changed, 222 insertions(+), 260 deletions(-) diff --git a/db/db.go b/db/db.go index e21b0590..3b6585c1 100644 --- a/db/db.go +++ b/db/db.go @@ -91,3 +91,36 @@ func extractDurationParam(q url.Values, key string, defaultValue time.Duration) } return d, nil } + +// ExecTx executes a function within a database transaction. If the function returns an error, +// the transaction is rolled back. Otherwise, the transaction is committed. +func ExecTx(db *sql.DB, f func(tx *sql.Tx) error) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := f(tx); err != nil { + return err + } + return tx.Commit() +} + +// QueryTx executes a function within a database transaction and returns the result. If the function +// returns an error, the transaction is rolled back. Otherwise, the transaction is committed. +func QueryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) { + tx, err := db.Begin() + if err != nil { + var zero T + return zero, err + } + defer tx.Rollback() + t, err := f(tx) + if err != nil { + return t, err + } + if err := tx.Commit(); err != nil { + return t, err + } + return t, nil +} diff --git a/message/cache.go b/message/cache.go index 953a6f7f..3b12af3e 100644 --- a/message/cache.go +++ b/message/cache.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/util" @@ -334,17 +335,14 @@ func (c *Cache) Topics() ([]string, error) { func (c *Cache) DeleteMessages(ids ...string) error { c.maybeLock() defer c.maybeUnlock() - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, id := range ids { - if _, err := tx.Exec(c.queries.deleteMessage, id); err != nil { - return err + return db.ExecTx(c.db, func(tx *sql.Tx) error { + for _, id := range ids { + if _, err := tx.Exec(c.queries.deleteMessage, id); err != nil { + return err + } } - } - return tx.Commit() + return nil + }) } // DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID. @@ -352,54 +350,43 @@ func (c *Cache) DeleteMessages(ids ...string) error { func (c *Cache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) { c.maybeLock() defer c.maybeUnlock() - tx, err := c.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - // First, get the message IDs of scheduled messages to be deleted - rows, err := tx.Query(c.queries.selectScheduledMessageIDsBySeqID, topic, sequenceID) - if err != nil { - return nil, err - } - defer rows.Close() - ids := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { + return db.QueryTx(c.db, func(tx *sql.Tx) ([]string, error) { + rows, err := tx.Query(c.queries.selectScheduledMessageIDsBySeqID, topic, sequenceID) + if err != nil { return nil, err } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - rows.Close() // Close rows before executing delete in same transaction - // Then delete the messages - if _, err := tx.Exec(c.queries.deleteScheduledBySequenceID, topic, sequenceID); err != nil { - return nil, err - } - if err := tx.Commit(); err != nil { - return nil, err - } - return ids, nil + defer rows.Close() + ids := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + rows.Close() // Close rows before executing delete in same transaction + if _, err := tx.Exec(c.queries.deleteScheduledBySequenceID, topic, sequenceID); err != nil { + return nil, err + } + return ids, nil + }) } // ExpireMessages marks messages in the given topics as expired func (c *Cache) ExpireMessages(topics ...string) error { c.maybeLock() defer c.maybeUnlock() - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, t := range topics { - if _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil { - return err + return db.ExecTx(c.db, func(tx *sql.Tx) error { + for _, t := range topics { + if _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil { + return err + } } - } - return tx.Commit() + return nil + }) } // AttachmentsExpired returns message IDs with expired attachments that have not been deleted @@ -427,17 +414,14 @@ func (c *Cache) AttachmentsExpired() ([]string, error) { func (c *Cache) MarkAttachmentsDeleted(ids ...string) error { c.maybeLock() defer c.maybeUnlock() - tx, err := c.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, id := range ids { - if _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil { - return err + return db.ExecTx(c.db, func(tx *sql.Tx) error { + for _, id := range ids { + if _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil { + return err + } } - } - return tx.Commit() + return nil + }) } // AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender diff --git a/message/cache_postgres_schema.go b/message/cache_postgres_schema.go index 4b5730a0..4a539d0e 100644 --- a/message/cache_postgres_schema.go +++ b/message/cache_postgres_schema.go @@ -3,6 +3,8 @@ package message import ( "database/sql" "fmt" + + "heckel.io/ntfy/v2/db" ) // Initial PostgreSQL schema @@ -55,34 +57,29 @@ const ( // PostgreSQL schema management queries const ( - pgCurrentSchemaVersion = 14 + postgresCurrentSchemaVersion = 14 postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)` postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'` ) func setupPostgres(db *sql.DB) error { var schemaVersion int - err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) - if err != nil { + if err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { return setupNewPostgresDB(db) - } - if schemaVersion > pgCurrentSchemaVersion { - return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion) + } else if schemaVersion > postgresCurrentSchemaVersion { + return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion) } return nil } -func setupNewPostgresDB(db *sql.DB) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { - return err - } - if _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil { - return err - } - return tx.Commit() +func setupNewPostgresDB(sqlDB *sql.DB) error { + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { + return err + } + if _, err := tx.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil { + return err + } + return nil + }) } diff --git a/message/cache_sqlite_schema.go b/message/cache_sqlite_schema.go index 00a26c09..da744887 100644 --- a/message/cache_sqlite_schema.go +++ b/message/cache_sqlite_schema.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/log" ) @@ -382,85 +383,70 @@ func sqliteMigrateFrom8(db *sql.DB, _ time.Duration) error { return nil } -func sqliteMigrateFrom9(db *sql.DB, cacheDuration time.Duration) error { +func sqliteMigrateFrom9(sqlDB *sql.DB, cacheDuration time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil { - return err - } - return tx.Commit() + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom10(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom10(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil { - return err - } - return tx.Commit() + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom11(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom11(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil { - return err - } - return tx.Commit() + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom12(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom12(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil { - return err - } - return tx.Commit() + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom13(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom13(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil { - return err - } - return tx.Commit() + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil { + return err + } + return nil + }) } diff --git a/user/manager.go b/user/manager.go index a1412be3..cd37cb3b 100644 --- a/user/manager.go +++ b/user/manager.go @@ -13,6 +13,7 @@ import ( "time" "golang.org/x/crypto/bcrypt" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/util" @@ -122,7 +123,7 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err if err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { return a.addUserTx(tx, username, hash, role, false) }) } @@ -150,7 +151,7 @@ func (a *Manager) RemoveUser(username string) error { if err := a.CanChangeUser(username); err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { return a.removeUserTx(tx, username) }) } @@ -173,7 +174,7 @@ func (a *Manager) MarkUserRemoved(user *User) error { if !AllowedUsername(user.Name) { return ErrInvalidArgument } - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { if err := a.resetUserAccessTx(tx, user.Name); err != nil { return err } @@ -205,7 +206,7 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { if err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { return a.changePasswordHashTx(tx, username, hash) }) } @@ -224,7 +225,7 @@ func (a *Manager) ChangeRole(username string, role Role) error { if err := a.CanChangeUser(username); err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { return a.changeRoleTx(tx, username, role) }) } @@ -365,7 +366,7 @@ func (a *Manager) writeUserStatsQueue() error { a.statsQueue = make(map[string]*Stats) a.mu.Unlock() - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { log.Tag(tag).Debug("Writing user stats queue for %d user(s)", len(statsQueue)) for userID, update := range statsQueue { log. @@ -573,7 +574,7 @@ func (a *Manager) resolvePerms(base, perm Permission) error { // read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry // owner may either be a user (username), or the system (empty). func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { return a.allowAccessTx(tx, username, topicPattern, permission, false) }) } @@ -591,7 +592,7 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is // empty) for an entire user. The parameter topicPattern may include wildcards (*). func (a *Manager) ResetAccess(username string, topicPattern string) error { - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { return a.resetAccessTx(tx, username, topicPattern) }) } @@ -715,7 +716,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { return ErrInvalidArgument } - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { if err := a.addReservationAccessTx(tx, username, topic, true, true, username); err != nil { return err } @@ -735,7 +736,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { return ErrInvalidArgument } } - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { for _, topic := range topics { if err := a.resetTopicAccessTx(tx, username, topic); err != nil { return err @@ -874,7 +875,7 @@ func (a *Manager) resetTopicAccessTx(tx *sql.Tx, username, topicPattern string) // after a fixed duration unless ChangeToken is called. This function also prunes tokens for the // given user, if there are too many of them. func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) { - return queryTx(a.db, func(tx *sql.Tx) (*Token, error) { + return db.QueryTx(a.db, func(tx *sql.Tx) (*Token, error) { return a.createTokenTx(tx, userID, GenerateToken(), label, time.Now(), origin, expires, tokenMaxCount, provisioned) }) } @@ -1033,7 +1034,7 @@ func (a *Manager) writeTokenUpdateQueue() error { a.tokenQueue = make(map[string]*TokenUpdate) a.mu.Unlock() - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) for tokenID, update := range tokenQueue { log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) @@ -1254,7 +1255,7 @@ func (a *Manager) maybeProvisionUsersAccessAndTokens() error { if err != nil { return err } - return execTx(a.db, func(tx *sql.Tx) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { if err := a.maybeProvisionUsers(tx, provisionUsernames, existingUsers); err != nil { return fmt.Errorf("failed to provision users: %v", err) } diff --git a/user/util.go b/user/util.go index 6354464d..16f6cc09 100644 --- a/user/util.go +++ b/user/util.go @@ -113,35 +113,3 @@ func escapeUnderscore(s string) string { func unescapeUnderscore(s string) string { return strings.ReplaceAll(s, "\\_", "_") } - -// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back. -func execTx(db *sql.DB, f func(tx *sql.Tx) error) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := f(tx); err != nil { - return err - } - return tx.Commit() -} - -// queryTx executes a function in a transaction and returns the result. If the function -// returns an error, the transaction is rolled back. -func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) { - tx, err := db.Begin() - if err != nil { - var zero T - return zero, err - } - defer tx.Rollback() - t, err := f(tx) - if err != nil { - return t, err - } - if err := tx.Commit(); err != nil { - return t, err - } - return t, nil -} diff --git a/webpush/store.go b/webpush/store.go index 20ff19cc..ab5c517b 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -6,6 +6,7 @@ import ( "net/netip" "time" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/util" ) @@ -46,41 +47,38 @@ type queries struct { // UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. func (s *Store) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - // Read number of subscriptions for subscriber IP address - var subscriptionCount int - if err := tx.QueryRow(s.queries.selectSubscriptionCountBySubscriberIP, subscriberIP.String()).Scan(&subscriptionCount); err != nil { - return err - } - // Read existing subscription ID for endpoint (or create new ID) - var subscriptionID string - if err := tx.QueryRow(s.queries.selectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID); errors.Is(err, sql.ErrNoRows) { - if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { - return ErrWebPushTooManySubscriptions - } - subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) - } else if err != nil { - return err - } - // Insert or update subscription - updatedAt, warnedAt := time.Now().Unix(), 0 - if _, err := tx.Exec(s.queries.upsertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { - return err - } - // Replace all subscription topics - if _, err := tx.Exec(s.queries.deleteSubscriptionTopicAll, subscriptionID); err != nil { - return err - } - for _, topic := range topics { - if _, err = tx.Exec(s.queries.insertSubscriptionTopic, subscriptionID, topic); err != nil { + return db.ExecTx(s.db, func(tx *sql.Tx) error { + // Read number of subscriptions for subscriber IP address + var subscriptionCount int + if err := tx.QueryRow(s.queries.selectSubscriptionCountBySubscriberIP, subscriberIP.String()).Scan(&subscriptionCount); err != nil { return err } - } - return tx.Commit() + // Read existing subscription ID for endpoint (or create new ID) + var subscriptionID string + if err := tx.QueryRow(s.queries.selectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID); errors.Is(err, sql.ErrNoRows) { + if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP { + return ErrWebPushTooManySubscriptions + } + subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength) + } else if err != nil { + return err + } + // Insert or update subscription + updatedAt, warnedAt := time.Now().Unix(), 0 + if _, err := tx.Exec(s.queries.upsertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { + return err + } + // Replace all subscription topics + if _, err := tx.Exec(s.queries.deleteSubscriptionTopicAll, subscriptionID); err != nil { + return err + } + for _, topic := range topics { + if _, err := tx.Exec(s.queries.insertSubscriptionTopic, subscriptionID, topic); err != nil { + return err + } + } + return nil + }) } // SubscriptionsForTopic returns all subscriptions for the given topic. @@ -105,17 +103,14 @@ func (s *Store) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, // MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon. func (s *Store) MarkExpiryWarningSent(subscriptions []*Subscription) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, subscription := range subscriptions { - if _, err := tx.Exec(s.queries.updateSubscriptionWarningSent, time.Now().Unix(), subscription.ID); err != nil { - return err + return db.ExecTx(s.db, func(tx *sql.Tx) error { + for _, subscription := range subscriptions { + if _, err := tx.Exec(s.queries.updateSubscriptionWarningSent, time.Now().Unix(), subscription.ID); err != nil { + return err + } } - } - return tx.Commit() + return nil + }) } // RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint. diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index d9c61470..ec541d37 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -3,6 +3,8 @@ package webpush import ( "database/sql" "fmt" + + "heckel.io/ntfy/v2/db" ) const ( @@ -107,17 +109,14 @@ func setupPostgres(db *sql.DB) error { return nil } -func setupNewPostgres(db *sql.DB) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { - return err - } - if _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil { - return err - } - return tx.Commit() +func setupNewPostgres(sqlDB *sql.DB) error { + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { + return err + } + if _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil { + return err + } + return nil + }) } diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index d41e1c8d..90ca14a8 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -5,6 +5,8 @@ import ( "fmt" _ "github.com/mattn/go-sqlite3" // SQLite driver + + "heckel.io/ntfy/v2/db" ) const ( @@ -119,19 +121,16 @@ func setupSQLite(db *sql.DB) error { return nil } -func setupNewSQLite(db *sql.DB) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil { - return err - } - if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil { - return err - } - return tx.Commit() +func setupNewSQLite(sqlDB *sql.DB) error { + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil { + return err + } + return nil + }) } func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { From 8afeb813d929bbddeae8d4bf6228d869f98240ec Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 2 Mar 2026 19:52:36 -0500 Subject: [PATCH 256/548] Move OpenPostgres --- cmd/user.go | 4 +- db/db.go | 88 --------------------------------------- db/pg/pg.go | 93 ++++++++++++++++++++++++++++++++++++++++++ db/test/test.go | 8 ++-- server/server.go | 6 +-- tools/pgimport/main.go | 4 +- user/manager_test.go | 4 +- 7 files changed, 106 insertions(+), 101 deletions(-) create mode 100644 db/pg/pg.go diff --git a/cmd/user.go b/cmd/user.go index e0041151..1ffc3e6b 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -11,7 +11,7 @@ import ( "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -379,7 +379,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, } if databaseURL != "" { - pool, dbErr := db.OpenPostgres(databaseURL) + pool, dbErr := pg.Open(databaseURL) if dbErr != nil { return nil, dbErr } diff --git a/db/db.go b/db/db.go index 3b6585c1..00ae91f4 100644 --- a/db/db.go +++ b/db/db.go @@ -2,96 +2,8 @@ package db import ( "database/sql" - "fmt" - "net/url" - "strconv" - "time" - - _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) -const ( - paramMaxOpenConns = "pool_max_conns" - paramMaxIdleConns = "pool_max_idle_conns" - paramConnMaxLifetime = "pool_conn_max_lifetime" - paramConnMaxIdleTime = "pool_conn_max_idle_time" - - defaultMaxOpenConns = 10 -) - -// OpenPostgres opens a PostgreSQL database connection pool from a DSN string. It supports custom -// query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns, -// pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from -// the DSN before passing it to the driver. -func OpenPostgres(dsn string) (*sql.DB, error) { - u, err := url.Parse(dsn) - if err != nil { - return nil, fmt.Errorf("invalid database URL: %w", err) - } - q := u.Query() - maxOpenConns, err := extractIntParam(q, paramMaxOpenConns, defaultMaxOpenConns) - if err != nil { - return nil, err - } - maxIdleConns, err := extractIntParam(q, paramMaxIdleConns, 0) - if err != nil { - return nil, err - } - connMaxLifetime, err := extractDurationParam(q, paramConnMaxLifetime, 0) - if err != nil { - return nil, err - } - connMaxIdleTime, err := extractDurationParam(q, paramConnMaxIdleTime, 0) - if err != nil { - return nil, err - } - u.RawQuery = q.Encode() - db, err := sql.Open("pgx", u.String()) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(maxOpenConns) - if maxIdleConns > 0 { - db.SetMaxIdleConns(maxIdleConns) - } - if connMaxLifetime > 0 { - db.SetConnMaxLifetime(connMaxLifetime) - } - if connMaxIdleTime > 0 { - db.SetConnMaxIdleTime(connMaxIdleTime) - } - if err := db.Ping(); err != nil { - return nil, fmt.Errorf("ping failed: %w", err) - } - return db, nil -} - -func extractIntParam(q url.Values, key string, defaultValue int) (int, error) { - s := q.Get(key) - if s == "" { - return defaultValue, nil - } - q.Del(key) - v, err := strconv.Atoi(s) - if err != nil { - return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) - } - return v, nil -} - -func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) { - s := q.Get(key) - if s == "" { - return defaultValue, nil - } - q.Del(key) - d, err := time.ParseDuration(s) - if err != nil { - return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) - } - return d, nil -} - // ExecTx executes a function within a database transaction. If the function returns an error, // the transaction is rolled back. Otherwise, the transaction is committed. func ExecTx(db *sql.DB, f func(tx *sql.Tx) error) error { diff --git a/db/pg/pg.go b/db/pg/pg.go new file mode 100644 index 00000000..99f802b3 --- /dev/null +++ b/db/pg/pg.go @@ -0,0 +1,93 @@ +package pg + +import ( + "database/sql" + "fmt" + "net/url" + "strconv" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver +) + +const ( + paramMaxOpenConns = "pool_max_conns" + paramMaxIdleConns = "pool_max_idle_conns" + paramConnMaxLifetime = "pool_conn_max_lifetime" + paramConnMaxIdleTime = "pool_conn_max_idle_time" + + defaultMaxOpenConns = 10 +) + +// Open opens a PostgreSQL database connection pool from a DSN string. It supports custom +// query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns, +// pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from +// the DSN before passing it to the driver. +func Open(dsn string) (*sql.DB, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("invalid database URL: %w", err) + } + q := u.Query() + maxOpenConns, err := extractIntParam(q, paramMaxOpenConns, defaultMaxOpenConns) + if err != nil { + return nil, err + } + maxIdleConns, err := extractIntParam(q, paramMaxIdleConns, 0) + if err != nil { + return nil, err + } + connMaxLifetime, err := extractDurationParam(q, paramConnMaxLifetime, 0) + if err != nil { + return nil, err + } + connMaxIdleTime, err := extractDurationParam(q, paramConnMaxIdleTime, 0) + if err != nil { + return nil, err + } + u.RawQuery = q.Encode() + db, err := sql.Open("pgx", u.String()) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(maxOpenConns) + if maxIdleConns > 0 { + db.SetMaxIdleConns(maxIdleConns) + } + if connMaxLifetime > 0 { + db.SetConnMaxLifetime(connMaxLifetime) + } + if connMaxIdleTime > 0 { + db.SetConnMaxIdleTime(connMaxIdleTime) + } + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping failed: %w", err) + } + return db, nil +} + +func extractIntParam(q url.Values, key string, defaultValue int) (int, error) { + s := q.Get(key) + if s == "" { + return defaultValue, nil + } + q.Del(key) + v, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) + } + return v, nil +} + +func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) { + s := q.Get(key) + if s == "" { + return defaultValue, nil + } + q.Del(key) + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err) + } + return d, nil +} diff --git a/db/test/test.go b/db/test/test.go index d843becb..36c3fc86 100644 --- a/db/test/test.go +++ b/db/test/test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/util" ) @@ -30,7 +30,7 @@ func CreateTestPostgresSchema(t *testing.T) string { q.Set("pool_max_conns", testPoolMaxConns) u.RawQuery = q.Encode() dsn = u.String() - setupDB, err := db.OpenPostgres(dsn) + setupDB, err := pg.Open(dsn) require.Nil(t, err) _, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) require.Nil(t, err) @@ -39,7 +39,7 @@ func CreateTestPostgresSchema(t *testing.T) string { u.RawQuery = q.Encode() schemaDSN := u.String() t.Cleanup(func() { - cleanDB, err := db.OpenPostgres(dsn) + cleanDB, err := pg.Open(dsn) if err == nil { cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) cleanDB.Close() @@ -54,7 +54,7 @@ func CreateTestPostgresSchema(t *testing.T) string { func CreateTestPostgres(t *testing.T) *sql.DB { t.Helper() schemaDSN := CreateTestPostgresSchema(t) - testDB, err := db.OpenPostgres(schemaDSN) + testDB, err := pg.Open(schemaDSN) require.Nil(t, err) t.Cleanup(func() { testDB.Close() diff --git a/server/server.go b/server/server.go index 2c09b56a..329b0ab5 100644 --- a/server/server.go +++ b/server/server.go @@ -33,7 +33,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" - "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/model" @@ -178,11 +178,11 @@ func New(conf *Config) (*Server, error) { if payments.Available && conf.StripeSecretKey != "" { stripe = newStripeAPI() } - // OpenPostgres shared PostgreSQL connection pool if configured + // Open shared PostgreSQL connection pool if configured var pool *sql.DB if conf.DatabaseURL != "" { var err error - pool, err = db.OpenPostgres(conf.DatabaseURL) + pool, err = pg.Open(conf.DatabaseURL) if err != nil { return nil, err } diff --git a/tools/pgimport/main.go b/tools/pgimport/main.go index 9637d33d..9af2212d 100644 --- a/tools/pgimport/main.go +++ b/tools/pgimport/main.go @@ -11,7 +11,7 @@ import ( "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "gopkg.in/yaml.v2" - "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/db/pg" ) const ( @@ -79,7 +79,7 @@ func execImport(c *cli.Context) error { } fmt.Println() - pgDB, err := db.OpenPostgres(databaseURL) + pgDB, err := pg.Open(databaseURL) if err != nil { return fmt.Errorf("cannot connect to PostgreSQL: %w", err) } diff --git a/user/manager_test.go b/user/manager_test.go index fd1e59fc..c353acb8 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" - "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/db/pg" dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/util" ) @@ -36,7 +36,7 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newManager newManagerFunc t.Run("postgres", func(t *testing.T) { schemaDSN := dbtest.CreateTestPostgresSchema(t) f(t, func(config *Config) *Manager { - pool, err := db.OpenPostgres(schemaDSN) + pool, err := pg.Open(schemaDSN) require.Nil(t, err) a, err := NewPostgresManager(pool, config) require.Nil(t, err) From c864a9baeba6ad7f2afbb4edf1c32c299ff11ca3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 2 Mar 2026 19:58:26 -0500 Subject: [PATCH 257/548] Put more things in tx --- webpush/store.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/webpush/store.go b/webpush/store.go index ab5c517b..9a93a074 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -130,12 +130,13 @@ func (s *Store) RemoveSubscriptionsByUserID(userID string) error { // RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period. func (s *Store) RemoveExpiredSubscriptions(expireAfter time.Duration) error { - _, err := s.db.Exec(s.queries.deleteSubscriptionByAge, time.Now().Add(-expireAfter).Unix()) - if err != nil { + return db.ExecTx(s.db, func(tx *sql.Tx) error { + if _, err := tx.Exec(s.queries.deleteSubscriptionByAge, time.Now().Add(-expireAfter).Unix()); err != nil { + return err + } + _, err := tx.Exec(s.queries.deleteSubscriptionTopicWithoutSubscription) return err - } - _, err = s.db.Exec(s.queries.deleteSubscriptionTopicWithoutSubscription) - return err + }) } // SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is From c4b8cfa75647da44214f0e6c54646ec7ffd57768 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 2 Mar 2026 20:04:03 -0500 Subject: [PATCH 258/548] Manual correction --- user/manager_sqlite_schema.go | 3 +-- webpush/store_sqlite.go | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go index 4fbd0c02..e6c84e01 100644 --- a/user/manager_sqlite_schema.go +++ b/user/manager_sqlite_schema.go @@ -328,8 +328,7 @@ var ( func setupSQLite(db *sql.DB) error { var schemaVersion int - err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) - if err != nil { + if err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { return setupNewSQLite(db) } if schemaVersion == sqliteCurrentSchemaVersion { diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 90ca14a8..4ef78140 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -111,11 +111,9 @@ func NewSQLiteStore(filename, startupQueries string) (*Store, error) { func setupSQLite(db *sql.DB) error { var schemaVersion int - err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) - if err != nil { + if err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { return setupNewSQLite(db) - } - if schemaVersion > sqliteCurrentSchemaVersion { + } else if schemaVersion > sqliteCurrentSchemaVersion { return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion) } return nil From bedbb121e4c324dafe1d18de32946a8cd19814cc Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 2 Mar 2026 20:05:29 -0500 Subject: [PATCH 259/548] Remove codecov.io --- .github/workflows/test.yaml | 2 -- Makefile | 4 ---- 2 files changed, 6 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2722d144..8d80b960 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -42,5 +42,3 @@ jobs: run: make checkv - name: Run coverage run: make coverage - - name: Upload coverage to codecov.io - run: make coverage-upload diff --git a/Makefile b/Makefile index 3683ab3c..997ea54c 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,6 @@ help: @echo " make race - Run tests with -race flag" @echo " make coverage - Run tests and show coverage" @echo " make coverage-html - Run tests and show coverage (as HTML)" - @echo " make coverage-upload - Upload coverage results to codecov.io" @echo @echo "Lint/format:" @echo " make fmt - Run 'go fmt'" @@ -286,9 +285,6 @@ coverage-html: go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') go tool cover -html build/coverage/coverage.txt -coverage-upload: - cd build/coverage && (curl -s https://codecov.io/bash | bash) - # Lint/formatting targets From 66449bd19bded659f282003c9a588210e3187eb3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 2 Mar 2026 20:15:35 -0500 Subject: [PATCH 260/548] pgimport readme --- tools/pgimport/README.md | 6 +++++- tools/pgimport/main.go | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/pgimport/README.md b/tools/pgimport/README.md index 2c645405..f7352111 100644 --- a/tools/pgimport/README.md +++ b/tools/pgimport/README.md @@ -1,6 +1,10 @@ # pgimport -Migrates ntfy data from SQLite to PostgreSQL. +One-off migration script to import ntfy data from SQLite to PostgreSQL. + +This is **not** a generic migration tool. It only works with specific SQLite schema versions +(message cache v14, user db v6, web push v1) and their corresponding PostgreSQL schemas. +If your database versions differ, this tool will refuse to run. ## Build diff --git a/tools/pgimport/main.go b/tools/pgimport/main.go index 9af2212d..bf92a2a7 100644 --- a/tools/pgimport/main.go +++ b/tools/pgimport/main.go @@ -1,3 +1,6 @@ +// pgimport is a one-off migration script to import ntfy data from SQLite to PostgreSQL. +// It is not a generic migration tool. It expects specific schema versions for each database +// (message cache v14, user db v6, web push v1) and will refuse to run if versions don't match. package main import ( @@ -33,7 +36,7 @@ var flags = []cli.Flag{ func main() { app := &cli.App{ Name: "pgimport", - Usage: "SQLite to PostgreSQL migration tool for ntfy", + Usage: "One-off SQLite to PostgreSQL migration script for ntfy", UsageText: "pgimport [OPTIONS]", Flags: flags, Before: loadConfigFile("config", flags), From fb26e7ef3a246273a5d9c5574f2418c4fbbdbbdd Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 3 Mar 2026 09:42:59 -0500 Subject: [PATCH 261/548] BUmp --- go.mod | 54 ++-- go.sum | 116 ++++---- web/package-lock.json | 674 +++++++++++++++--------------------------- 3 files changed, 318 insertions(+), 526 deletions(-) diff --git a/go.mod b/go.mod index 992aced8..9bd08894 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,25 @@ module heckel.io/ntfy/v2 -go 1.24.6 +go 1.25.0 require ( cloud.google.com/go/firestore v1.21.0 // indirect - cloud.google.com/go/storage v1.59.2 // indirect + cloud.google.com/go/storage v1.60.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.18.0 github.com/gabriel-vasile/mimetype v1.4.13 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.33 + github.com/mattn/go-sqlite3 v1.14.34 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.47.0 - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/crypto v0.48.0 + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 - golang.org/x/term v0.39.0 + golang.org/x/term v0.40.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.265.0 + google.golang.org/api v0.269.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -33,14 +33,14 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/sys v0.40.0 - golang.org/x/text v0.33.0 + golang.org/x/sys v0.41.0 + golang.org/x/text v0.34.0 ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth v0.18.2 // 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.5.3 // indirect @@ -57,8 +57,8 @@ require ( github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -68,7 +68,7 @@ require ( 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.11 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -76,27 +76,27 @@ require ( 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.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.41.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/sdk v1.41.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c42210b4..4344e0f6 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/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.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= -cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= 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= @@ -12,14 +12,14 @@ cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapW cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= -cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw= -cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8= +cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= @@ -58,14 +58,14 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI= github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= -github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= -github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= @@ -96,8 +96,8 @@ 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.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= -github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -112,8 +112,8 @@ 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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= -github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= @@ -133,8 +133,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -156,24 +156,24 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/contrib/detectors/gcp v1.41.0 h1:MBzEwqhroF0JK0DpTVYWDxsenxm6L4PqOEfA90uZ5AA= +go.opentelemetry.io/contrib/detectors/gcp v1.41.0/go.mod h1:5pSDD0v0t2HqUmPC5cBBc+nLQO4dLYWnzBNheXLBLgs= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 h1:w/o339tDd6Qtu3+ytwt+/jon2yjAs3Ot8Xq8pelfhSo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0/go.mod h1:pdhNtM9C4H5fRdrnwO7NjxzQWhKSSxCHk/KluVqDVC0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= +go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= +go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= +go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -184,8 +184,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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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= @@ -200,10 +200,10 @@ 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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -225,8 +225,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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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= @@ -236,8 +236,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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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= @@ -249,8 +249,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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -263,18 +263,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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU= -google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY= +google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= +google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= 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-20260203192932-546029d2fa20 h1:/CU1zrxTpGylJJbe3Ru94yy6sZRbzALq2/oxl3pGB3U= -google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20/go.mod h1:Tt+08/KdKEt3l8x3Pby3HLQxMB3uk/MzaQ4ZIv0ORTs= -google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= -google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 h1:RxhCsti413yL0IjU9dVvuTbCISo8gs3RW1jPMStck+4= +google.golang.org/genproto v0.0.0-20260226221140-a57be14db171/go.mod h1:uhvzakVEqAuXU3TC2JCsxIRe5f77l+JySE3EqPoMyqM= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 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= diff --git a/web/package-lock.json b/web/package-lock.json index 5a794eca..e459b113 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -108,9 +108,9 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -2298,74 +2298,14 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, "node_modules/@jridgewell/gen-mapping": { @@ -2798,9 +2738,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -2812,9 +2752,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -2826,9 +2766,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -2840,9 +2780,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -2854,9 +2794,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -2868,9 +2808,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -2882,9 +2822,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -2896,9 +2836,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -2910,9 +2850,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -2924,9 +2864,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -2938,9 +2878,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -2952,9 +2892,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -2966,9 +2906,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -2980,9 +2920,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -2994,9 +2934,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -3008,9 +2948,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -3022,9 +2962,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -3036,9 +2976,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -3050,9 +2990,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -3064,9 +3004,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -3078,9 +3018,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -3092,9 +3032,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -3106,9 +3046,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -3120,9 +3060,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -3134,9 +3074,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -3248,9 +3188,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.11", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.11.tgz", - "integrity": "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "peer": true, "dependencies": { @@ -3315,9 +3255,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -3338,9 +3278,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3702,13 +3642,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { @@ -3823,9 +3766,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001767", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", - "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "version": "1.0.30001776", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", "dev": true, "funding": [ { @@ -4243,13 +4186,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4267,9 +4203,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "dev": true, "license": "ISC" }, @@ -4848,19 +4784,25 @@ } }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -5052,9 +4994,9 @@ } }, "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5072,9 +5014,9 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -5123,9 +5065,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", "dev": true, "license": "ISC" }, @@ -5911,16 +5853,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6244,13 +6176,13 @@ } }, "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@isaacs/cliui": "^9.0.0" }, "engines": { "node": "20 || >=22" @@ -6691,9 +6623,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6714,11 +6646,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -6755,6 +6687,25 @@ "dev": true, "license": "MIT" }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -7074,9 +7025,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -7084,16 +7035,16 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7139,9 +7090,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -7628,9 +7579,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -7644,31 +7595,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -7960,11 +7911,14 @@ } }, "node_modules/smob": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", + "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/source-map": { "version": "0.5.7", @@ -8083,76 +8037,6 @@ "node": ">= 0.4" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -8294,20 +8178,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -9304,9 +9174,9 @@ "license": "MIT" }, "node_modules/workbox-build/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -9320,6 +9190,29 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/workbox-build/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/workbox-build/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/workbox-build/node_modules/estree-walker": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", @@ -9360,16 +9253,16 @@ "license": "MIT" }, "node_modules/workbox-build/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9402,9 +9295,9 @@ } }, "node_modules/workbox-build/node_modules/rollup": { - "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", "bin": { @@ -9597,107 +9490,6 @@ "workbox-core": "7.4.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", From 33b19814c71f7a5e66849b5928a1645f9085c194 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 3 Mar 2026 14:42:36 -0500 Subject: [PATCH 262/548] Transactions --- Makefile | 6 +- message/cache_sqlite_schema.go | 200 +++++++++++++++++---------------- user/manager_sqlite_schema.go | 189 ++++++++++++++----------------- 3 files changed, 195 insertions(+), 200 deletions(-) diff --git a/Makefile b/Makefile index 997ea54c..3805a19a 100644 --- a/Makefile +++ b/Makefile @@ -267,13 +267,13 @@ check: test web-fmt-check fmt-check vet web-lint lint staticcheck checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck test: .PHONY - go test -parallel 3 $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -parallel 3 $(shell go list ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)') testv: .PHONY - go test -v -parallel 3 $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -parallel 3 $(shell go list ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)') race: .PHONY - go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -race $(shell go list ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)') coverage: mkdir -p build/coverage diff --git a/message/cache_sqlite_schema.go b/message/cache_sqlite_schema.go index da744887..368594f6 100644 --- a/message/cache_sqlite_schema.go +++ b/message/cache_sqlite_schema.go @@ -12,7 +12,6 @@ import ( // Initial SQLite schema const ( sqliteCreateTablesQuery = ` - BEGIN; CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, mid TEXT NOT NULL, @@ -53,7 +52,6 @@ const ( value INT ); INSERT INTO stats (key, value) VALUES ('messages', 0); - COMMIT; ` ) @@ -75,11 +73,9 @@ const ( const ( // 0 -> 1 sqliteMigrate0To1AlterMessagesTableQuery = ` - BEGIN; ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0); ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT(''); - COMMIT; ` // 1 -> 2 @@ -89,7 +85,6 @@ const ( // 2 -> 3 sqliteMigrate2To3AlterMessagesTableQuery = ` - BEGIN; ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT(''); @@ -97,7 +92,6 @@ const ( ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0'); ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT(''); - COMMIT; ` // 3 -> 4 sqliteMigrate3To4AlterMessagesTableQuery = ` @@ -106,7 +100,6 @@ const ( // 4 -> 5 sqliteMigrate4To5AlterMessagesTableQuery = ` - BEGIN; CREATE TABLE IF NOT EXISTS messages_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, mid TEXT NOT NULL, @@ -138,7 +131,6 @@ const ( FROM messages; DROP TABLE messages; ALTER TABLE messages_new RENAME TO messages; - COMMIT; ` // 5 -> 6 @@ -259,17 +251,19 @@ func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) return nil } -func setupNewSQLite(db *sql.DB) error { - if _, err := db.Exec(sqliteCreateTablesQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); 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(sqliteCreateTablesQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil { + return err + } + return nil + }) } func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { @@ -281,106 +275,124 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { return nil } -func sqliteMigrateFrom0(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom0(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1") - if _, err := db.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom1(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom1(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2") - if _, err := db.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom2(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom2(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3") - if _, err := db.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom3(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom3(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4") - if _, err := db.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom4(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom4(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5") - if _, err := db.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom5(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom5(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6") - if _, err := db.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom6(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom6(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7") - if _, err := db.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom7(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom7(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8") - if _, err := db.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil { + return err + } + return nil + }) } -func sqliteMigrateFrom8(db *sql.DB, _ time.Duration) error { +func sqliteMigrateFrom8(sqlDB *sql.DB, _ time.Duration) error { log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9") - if _, err := db.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil { - return err - } - if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil { - return err - } - return nil + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil { + return err + } + return nil + }) } func sqliteMigrateFrom9(sqlDB *sql.DB, cacheDuration time.Duration) error { diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go index e6c84e01..01942163 100644 --- a/user/manager_sqlite_schema.go +++ b/user/manager_sqlite_schema.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" ) @@ -11,7 +12,6 @@ import ( // Initial SQLite schema const ( sqliteCreateTablesQueries = ` - BEGIN; CREATE TABLE IF NOT EXISTS tier ( id TEXT PRIMARY KEY, code TEXT NOT NULL, @@ -92,7 +92,6 @@ const ( INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH()) ON CONFLICT (id) DO NOTHING; - COMMIT; ` ) @@ -347,14 +346,16 @@ func setupSQLite(db *sql.DB) error { return nil } -func setupNewSQLite(db *sql.DB) error { - if _, err := db.Exec(sqliteCreateTablesQueries); err != nil { - return err - } - if _, err := db.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); 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 { @@ -369,114 +370,96 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { return nil } -func sqliteMigrateFrom1(db *sql.DB) error { +func sqliteMigrateFrom1(sqlDB *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 1 to 2") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - // 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 db.ExecTx(sqlDB, func(tx *sql.Tx) error { + // Rename user -> user_old, and create new tables + if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); 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 { + // Insert users from user_old into new user table, with ID and sync_topic + rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery) + if 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 - } - if err := tx.Commit(); err != nil { - return err - } - return nil + 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(db *sql.DB) error { +func sqliteMigrateFrom2(sqlDB *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 2 to 3") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil { - return err - } - return tx.Commit() + 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(db *sql.DB) error { +func sqliteMigrateFrom3(sqlDB *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 3 to 4") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil { - return err - } - return tx.Commit() + 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(db *sql.DB) error { +func sqliteMigrateFrom4(sqlDB *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 4 to 5") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil { - return err - } - return tx.Commit() + 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(db *sql.DB) error { +func sqliteMigrateFrom5(sqlDB *sql.DB) error { log.Tag(tag).Info("Migrating user database schema: from 5 to 6") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil { - return err - } - return tx.Commit() + 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 + }) } From bff2b47eb678fb4c2f02c374cba8fe5c5d6d3985 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 3 Mar 2026 14:52:29 -0500 Subject: [PATCH 263/548] Simplify schema reading --- message/cache_sqlite_schema.go | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/message/cache_sqlite_schema.go b/message/cache_sqlite_schema.go index 368594f6..8c68bad8 100644 --- a/message/cache_sqlite_schema.go +++ b/message/cache_sqlite_schema.go @@ -216,24 +216,13 @@ func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) return err } // If 'messages' table does not exist, this must be a new database - rowsMC, err := db.Query(sqliteSelectMessagesCountQuery) - if err != nil { + var messagesCount int + if err := db.QueryRow(sqliteSelectMessagesCountQuery).Scan(&messagesCount); err != nil { return setupNewSQLite(db) } - rowsMC.Close() - // If 'messages' table exists, check 'schemaVersion' table - schemaVersion := 0 - rowsSV, err := db.Query(sqliteSelectSchemaVersionQuery) - if err == nil { - defer rowsSV.Close() - if !rowsSV.Next() { - return fmt.Errorf("cannot determine schema version: cache file may be corrupt") - } - if err := rowsSV.Scan(&schemaVersion); err != nil { - return err - } - rowsSV.Close() - } + // If 'messages' table exists (schema >= 0), check 'schemaVersion' table + var schemaVersion int + db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) // Error means schema version is zero! // Do migrations if schemaVersion == sqliteCurrentSchemaVersion { return nil From d4038f566cf7faea22606b8be8ad2ae0ffc1f138 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Mar 2026 20:48:30 -0500 Subject: [PATCH 264/548] Release notes --- docs/releases.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index c2317180..ced747ea 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1724,3 +1724,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** * Preserve `
    ` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) + +### ntfy Android v1.24.x (UNRELEASED) + +**Features:** + +* Show notification when connection to server has been lost for 15+ minutes, with dismiss, snooze and never-show-again actions + +**Bug fixes + maintenance:** + +* Fix crash in settings when fragment is detached during backup/restore or log operations From 5610b7c56dcaeb43f7727df9da9b20ee87643e91 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Mar 2026 20:49:18 -0500 Subject: [PATCH 265/548] Bump --- go.mod | 2 +- go.sum | 4 ++-- web/package-lock.json | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 9bd08894..05515ca0 100644 --- a/go.mod +++ b/go.mod @@ -68,7 +68,7 @@ require ( 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.12 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.13 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index 4344e0f6..21ab3037 100644 --- a/go.sum +++ b/go.sum @@ -96,8 +96,8 @@ 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.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.13 h1:hSPAhW3NX+7HNlTsmrvU0jL75cIzxFktheceg95Nq14= +github.com/googleapis/enterprise-certificate-proxy v0.3.13/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= diff --git a/web/package-lock.json b/web/package-lock.json index e459b113..1902909b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6727,9 +6727,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "dev": true, "license": "MIT" }, From 59e6c16633463b103bbd52f492385dbba5ca7c2f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Mar 2026 21:07:21 -0500 Subject: [PATCH 266/548] Fix cov test --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2da5333e..6005cd64 100644 --- a/Makefile +++ b/Makefile @@ -276,7 +276,7 @@ race: .PHONY coverage: mkdir -p build/coverage - go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)') go tool cover -func build/coverage/coverage.txt coverage-html: From 8e91e028a0fc72690f9fd3c28af6dd992964239c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Mar 2026 22:34:34 -0500 Subject: [PATCH 267/548] Fix coverage issue --- Makefile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 6005cd64..5d2768f0 100644 --- a/Makefile +++ b/Makefile @@ -266,22 +266,22 @@ cli-build-results: check: test web-fmt-check fmt-check vet web-lint lint staticcheck test: .PHONY - go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/(test|examples|tools)') testv: .PHONY - go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/(test|examples|tools)') race: .PHONY - go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/(test|examples|tools)') coverage: mkdir -p build/coverage - go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)') + go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)') go tool cover -func build/coverage/coverage.txt coverage-html: mkdir -p build/coverage - go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/(test|examples|tools)') go tool cover -html build/coverage/coverage.txt coverage-upload: From 62bb335675a27f5ccf6cd3596636e18672e5c9f4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 4 Mar 2026 22:42:45 -0500 Subject: [PATCH 268/548] Grr --- Makefile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 5d2768f0..28b94f3f 100644 --- a/Makefile +++ b/Makefile @@ -265,14 +265,16 @@ cli-build-results: check: test web-fmt-check fmt-check vet web-lint lint staticcheck +checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck + test: .PHONY - go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/(test|examples|tools)') + go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') testv: .PHONY - go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') race: .PHONY - go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') coverage: mkdir -p build/coverage @@ -281,7 +283,7 @@ coverage: coverage-html: mkdir -p build/coverage - go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/(test|examples|tools)') + go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)') go tool cover -html build/coverage/coverage.txt coverage-upload: From 28c3fd5cbe2dbd138cf7f4881eedcf7b66506c0f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 6 Mar 2026 12:50:49 -0500 Subject: [PATCH 269/548] BUmp --- go.mod | 4 ++-- go.sum | 8 ++++---- web/package-lock.json | 36 ++++++++++++++++++------------------ 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 34115581..6dd9384f 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( 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.13 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -100,7 +100,7 @@ require ( google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/grpc v1.79.1 // indirect + google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d5612d81..c39fc3a1 100644 --- a/go.sum +++ b/go.sum @@ -96,8 +96,8 @@ 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.13 h1:hSPAhW3NX+7HNlTsmrvU0jL75cIzxFktheceg95Nq14= -github.com/googleapis/enterprise-certificate-proxy v0.3.13/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -282,8 +282,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 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= diff --git a/web/package-lock.json b/web/package-lock.json index 1902909b..e775138f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -194,9 +194,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.7.tgz", + "integrity": "sha512-6Fqi8MtQ/PweQ9xvux65emkLQ83uB+qAVtfHkC9UodyHMIZdxNI01HjLCLUtybElp2KY2XNE0nOgyP1E1vXw9w==", "dev": true, "license": "MIT", "dependencies": { @@ -3583,14 +3583,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", - "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz", + "integrity": "sha512-xaVwwSfebXf0ooE11BJovZYKhFjIvQo7TsyVpETuIeH2JHv0k/T6Y5j22pPTvqYqmpkxdlPAJlyJ0tfOJAoMxw==", "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.7", "semver": "^6.3.1" }, "peerDependencies": { @@ -3598,13 +3598,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", - "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.1.tgz", + "integrity": "sha512-ENp89vM9Pw4kv/koBb5N2f9bDZsR0hpf3BdPMOg/pkS3pwO4dzNnQZVXtBbeyAadgm865DmQG2jMMLqmZXvuCw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.7", "core-js-compat": "^3.48.0" }, "peerDependencies": { @@ -3612,13 +3612,13 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", - "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.7.tgz", + "integrity": "sha512-OTYbUlSwXhNgr4g6efMZgsO8//jA61P7ZbRX3iTT53VON8l+WQS8IAUEVo4a4cWknrg2W8Cj4gQhRYNCJ8GkAA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6" + "@babel/helper-define-polyfill-provider": "^0.6.7" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3766,9 +3766,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001776", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", - "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { From 0d4b1b00e6ecb71b6240c6ffa17564b6a437d6f2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 6 Mar 2026 14:46:53 -0500 Subject: [PATCH 270/548] Users() optimization to help with startup time --- .gitignore | 1 + tools/loadtest/go.mod | 5 + tools/loadtest/go.sum | 2 + tools/loadtest/main.go | 522 +++++++++++++++++++++++++++++++++++++++ tools/pgimport/README.md | 11 +- tools/pgimport/main.go | 188 ++++++++++++++ user/manager.go | 77 ++++-- user/manager_postgres.go | 30 ++- user/manager_sqlite.go | 30 ++- user/manager_test.go | 48 ++++ user/types.go | 3 + 11 files changed, 876 insertions(+), 41 deletions(-) create mode 100644 tools/loadtest/go.mod create mode 100644 tools/loadtest/go.sum create mode 100644 tools/loadtest/main.go diff --git a/.gitignore b/.gitignore index 3a362286..ed17b2d4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ server/docs/ server/site/ tools/fbsend/fbsend tools/pgimport/pgimport +tools/loadtest/loadtest playground/ secrets/ *.iml diff --git a/tools/loadtest/go.mod b/tools/loadtest/go.mod new file mode 100644 index 00000000..3c3034a9 --- /dev/null +++ b/tools/loadtest/go.mod @@ -0,0 +1,5 @@ +module loadtest + +go 1.25.2 + +require github.com/gorilla/websocket v1.5.3 diff --git a/tools/loadtest/go.sum b/tools/loadtest/go.sum new file mode 100644 index 00000000..25a9fc4b --- /dev/null +++ b/tools/loadtest/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/tools/loadtest/main.go b/tools/loadtest/main.go new file mode 100644 index 00000000..1cfbdd56 --- /dev/null +++ b/tools/loadtest/main.go @@ -0,0 +1,522 @@ +// Load test program for ntfy staging server. +// Replicates production traffic patterns derived from access.log analysis. +// +// Traffic profile (from ~5M requests over 20 hours): +// ~71 req/sec average, ~4,300 req/min +// 49.6% poll requests (GET /TOPIC/json?poll=1&since=ID) +// 21.4% publish POST (POST /TOPIC with small body) +// 6.2% subscribe stream (GET /TOPIC/json?since=X, long-lived) +// 4.1% config check (GET /v1/config) +// 2.3% other topic GET (GET /TOPIC) +// 2.2% account check (GET /v1/account) +// 1.9% websocket sub (GET /TOPIC/ws?since=X) +// 1.5% publish PUT (PUT /TOPIC with small body) +// 1.5% raw subscribe (GET /TOPIC/raw?since=X) +// 1.1% json subscribe (GET /TOPIC/json, no since) +// 0.7% SSE subscribe (GET /TOPIC/sse?since=X) +// remaining: static, PATCH, OPTIONS, etc. (omitted) + +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "io" + + "math/big" + mrand "math/rand" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +var ( + baseURL string + rps float64 + scale float64 + numTopics int + subStreams int + wsStreams int + sseStreams int + rawStreams int + duration time.Duration + + totalRequests atomic.Int64 + totalErrors atomic.Int64 + activeStreams atomic.Int64 + + // Error tracking by category + errMu sync.Mutex + recentErrors []string // last N unique error messages + errorCounts = make(map[string]int64) +) + +func main() { + flag.StringVar(&baseURL, "url", "https://staging.ntfy.sh", "Base URL of ntfy server") + flag.Float64Var(&rps, "rps", 71, "Target requests per second (default: prod average)") + flag.Float64Var(&scale, "scale", 1.0, "Scale factor for all load (0.5 = half load, 2.0 = double)") + flag.IntVar(&numTopics, "topics", 500, "Number of unique topics to use") + flag.IntVar(&subStreams, "sub-streams", 200, "Number of concurrent JSON streaming subscriptions") + flag.IntVar(&wsStreams, "ws-streams", 50, "Number of concurrent WebSocket subscriptions") + flag.IntVar(&sseStreams, "sse-streams", 20, "Number of concurrent SSE subscriptions") + flag.IntVar(&rawStreams, "raw-streams", 30, "Number of concurrent raw subscriptions") + flag.DurationVar(&duration, "duration", 10*time.Minute, "Test duration") + flag.Parse() + + rps *= scale + subStreams = int(float64(subStreams) * scale) + wsStreams = int(float64(wsStreams) * scale) + sseStreams = int(float64(sseStreams) * scale) + rawStreams = int(float64(rawStreams) * scale) + + topics := generateTopics(numTopics) + + fmt.Printf("ntfy load test\n") + fmt.Printf(" Target: %s\n", baseURL) + fmt.Printf(" RPS: %.1f\n", rps) + fmt.Printf(" Scale: %.1fx\n", scale) + fmt.Printf(" Topics: %d\n", numTopics) + fmt.Printf(" Sub streams: %d json, %d ws, %d sse, %d raw\n", subStreams, wsStreams, sseStreams, rawStreams) + fmt.Printf(" Duration: %s\n", duration) + fmt.Println() + + ctx, cancel := context.WithTimeout(context.Background(), duration) + defer cancel() + + // Also handle Ctrl+C + sigCtx, sigCancel := signal.NotifyContext(ctx, os.Interrupt) + defer sigCancel() + ctx = sigCtx + + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 1000, + MaxIdleConnsPerHost: 1000, + IdleConnTimeout: 90 * time.Second, + }, + } + + // Long-lived streaming client (no timeout) + streamClient := &http.Client{ + Timeout: 0, + Transport: &http.Transport{ + MaxIdleConns: 500, + MaxIdleConnsPerHost: 500, + IdleConnTimeout: 0, + }, + } + + var wg sync.WaitGroup + + // Start long-lived streaming subscriptions + for i := 0; i < subStreams; i++ { + wg.Add(1) + go func() { + defer wg.Done() + streamSubscription(ctx, streamClient, topics, "json") + }() + } + for i := 0; i < wsStreams; i++ { + wg.Add(1) + go func() { + defer wg.Done() + wsSubscription(ctx, topics) + }() + } + for i := 0; i < sseStreams; i++ { + wg.Add(1) + go func() { + defer wg.Done() + streamSubscription(ctx, streamClient, topics, "sse") + }() + } + for i := 0; i < rawStreams; i++ { + wg.Add(1) + go func() { + defer wg.Done() + streamSubscription(ctx, streamClient, topics, "raw") + }() + } + + // Start request generators based on traffic weights + // Weights from log analysis (normalized to sum ~100): + // poll=49.6, publish_post=21.4, config=4.1, other_get=2.3, account=2.2, publish_put=1.5 + // Total short-lived weight ≈ 81.1 + type requestType struct { + name string + weight float64 + fn func(ctx context.Context, client *http.Client, topics []string) + } + + types := []requestType{ + {"poll", 49.6, doPoll}, + {"publish_post", 21.4, doPublishPost}, + {"config", 4.1, doConfig}, + {"other_get", 2.3, doOtherGet}, + {"account", 2.2, doAccountCheck}, + {"publish_put", 1.5, doPublishPut}, + } + + totalWeight := 0.0 + for _, t := range types { + totalWeight += t.weight + } + + for _, t := range types { + t := t + typeRPS := rps * (t.weight / totalWeight) + if typeRPS < 0.1 { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + runAtRate(ctx, typeRPS, func() { + t.fn(ctx, client, topics) + }) + }() + } + + // Stats reporter + wg.Add(1) + go func() { + defer wg.Done() + reportStats(ctx) + }() + + wg.Wait() + fmt.Printf("\nDone. Total requests: %d, errors: %d\n", totalRequests.Load(), totalErrors.Load()) +} + +func trackError(category string, err error) { + totalErrors.Add(1) + key := fmt.Sprintf("%s: %s", category, truncateErr(err)) + errMu.Lock() + errorCounts[key]++ + errMu.Unlock() +} + +func trackErrorMsg(category string, msg string) { + totalErrors.Add(1) + key := fmt.Sprintf("%s: %s", category, msg) + errMu.Lock() + errorCounts[key]++ + errMu.Unlock() +} + +func truncateErr(err error) string { + s := err.Error() + if len(s) > 120 { + s = s[:120] + "..." + } + return s +} + +func generateTopics(n int) []string { + topics := make([]string, n) + for i := 0; i < n; i++ { + b := make([]byte, 8) + rand.Read(b) + topics[i] = "loadtest-" + hex.EncodeToString(b) + } + return topics +} + +func pickTopic(topics []string) string { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(topics)))) + return topics[n.Int64()] +} + +func randomSince() string { + b := make([]byte, 6) + rand.Read(b) + return hex.EncodeToString(b) +} + +func randomMessage() string { + messages := []string{ + "Test notification", + "Server backup completed successfully", + "Deployment finished", + "Alert: disk usage above 80%", + "Build #1234 passed", + "New order received", + "Temperature sensor reading: 72F", + "Cron job completed", + } + return messages[mrand.Intn(len(messages))] +} + +// runAtRate executes fn at approximately the given rate per second +func runAtRate(ctx context.Context, rate float64, fn func()) { + interval := time.Duration(float64(time.Second) / rate) + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + go fn() + } + } +} + +// --- Short-lived request types --- + +func doPoll(ctx context.Context, client *http.Client, topics []string) { + topic := pickTopic(topics) + url := fmt.Sprintf("%s/%s/json?poll=1&since=%s", baseURL, topic, randomSince()) + doGet(ctx, client, url) +} + +func doPublishPost(ctx context.Context, client *http.Client, topics []string) { + topic := pickTopic(topics) + url := fmt.Sprintf("%s/%s", baseURL, topic) + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(randomMessage())) + if err != nil { + trackError("publish_post_req", err) + return + } + // Some messages have titles/priorities like real traffic + if mrand.Float32() < 0.3 { + req.Header.Set("X-Title", "Load Test") + } + if mrand.Float32() < 0.1 { + req.Header.Set("X-Priority", fmt.Sprintf("%d", mrand.Intn(5)+1)) + } + resp, err := client.Do(req) + totalRequests.Add(1) + if err != nil { + trackError("publish_post", err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode >= 400 { + trackErrorMsg("publish_post_http", fmt.Sprintf("status %d", resp.StatusCode)) + } +} + +func doPublishPut(ctx context.Context, client *http.Client, topics []string) { + topic := pickTopic(topics) + url := fmt.Sprintf("%s/%s", baseURL, topic) + req, err := http.NewRequestWithContext(ctx, "PUT", url, strings.NewReader(randomMessage())) + if err != nil { + trackError("publish_put_req", err) + return + } + resp, err := client.Do(req) + totalRequests.Add(1) + if err != nil { + trackError("publish_put", err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode >= 400 { + trackErrorMsg("publish_put_http", fmt.Sprintf("status %d", resp.StatusCode)) + } +} + +func doConfig(ctx context.Context, client *http.Client, topics []string) { + url := fmt.Sprintf("%s/v1/config", baseURL) + doGet(ctx, client, url) +} + +func doAccountCheck(ctx context.Context, client *http.Client, topics []string) { + url := fmt.Sprintf("%s/v1/account", baseURL) + doGet(ctx, client, url) +} + +func doOtherGet(ctx context.Context, client *http.Client, topics []string) { + topic := pickTopic(topics) + url := fmt.Sprintf("%s/%s", baseURL, topic) + doGet(ctx, client, url) +} + +func doGet(ctx context.Context, client *http.Client, url string) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + trackError("get_req", err) + return + } + resp, err := client.Do(req) + totalRequests.Add(1) + if err != nil { + trackError("get", err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode >= 400 { + trackErrorMsg("get_http", fmt.Sprintf("status %d for %s", resp.StatusCode, url)) + } +} + +// --- Long-lived streaming subscriptions --- + +func streamSubscription(ctx context.Context, client *http.Client, topics []string, format string) { + for { + if ctx.Err() != nil { + return + } + topic := pickTopic(topics) + url := fmt.Sprintf("%s/%s/%s?since=all", baseURL, topic, format) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + time.Sleep(time.Second) + continue + } + activeStreams.Add(1) + resp, err := client.Do(req) + if err != nil { + activeStreams.Add(-1) + if ctx.Err() == nil { + trackError("stream_"+format+"_connect", err) + } + time.Sleep(time.Second) + continue + } + if resp.StatusCode >= 400 { + trackErrorMsg("stream_"+format+"_http", fmt.Sprintf("status %d", resp.StatusCode)) + resp.Body.Close() + activeStreams.Add(-1) + time.Sleep(time.Second) + continue + } + // Read from stream until context cancelled or connection drops + buf := make([]byte, 4096) + for { + _, err := resp.Body.Read(buf) + if err != nil { + if ctx.Err() == nil { + trackError("stream_"+format+"_read", err) + } + break + } + } + resp.Body.Close() + activeStreams.Add(-1) + // Reconnect with small delay (like real clients do) + select { + case <-ctx.Done(): + return + case <-time.After(time.Duration(mrand.Intn(3000)) * time.Millisecond): + } + } +} + +func wsSubscription(ctx context.Context, topics []string) { + wsURL := strings.Replace(baseURL, "https://", "wss://", 1) + wsURL = strings.Replace(wsURL, "http://", "ws://", 1) + + for { + if ctx.Err() != nil { + return + } + topic := pickTopic(topics) + url := fmt.Sprintf("%s/%s/ws?since=all", wsURL, topic) + + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + activeStreams.Add(1) + conn, _, err := dialer.DialContext(ctx, url, nil) + if err != nil { + activeStreams.Add(-1) + if ctx.Err() == nil { + trackError("ws_connect", err) + } + time.Sleep(time.Second) + continue + } + + // Read messages until context cancelled or error + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn.SetReadDeadline(time.Now().Add(5 * time.Minute)) + _, _, err := conn.ReadMessage() + if err != nil { + return + } + } + }() + + select { + case <-ctx.Done(): + conn.Close() + activeStreams.Add(-1) + return + case <-done: + conn.Close() + activeStreams.Add(-1) + } + + select { + case <-ctx.Done(): + return + case <-time.After(time.Duration(mrand.Intn(3000)) * time.Millisecond): + } + } +} + +func reportStats(ctx context.Context) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + var lastRequests, lastErrors int64 + lastTime := time.Now() + reportCount := 0 + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + now := time.Now() + currentRequests := totalRequests.Load() + currentErrors := totalErrors.Load() + elapsed := now.Sub(lastTime).Seconds() + currentRPS := float64(currentRequests-lastRequests) / elapsed + errorRate := float64(currentErrors-lastErrors) / elapsed + + fmt.Printf("[%s] rps=%.1f err/s=%.1f total=%d errors=%d streams=%d\n", + now.Format("15:04:05"), + currentRPS, + errorRate, + currentRequests, + currentErrors, + activeStreams.Load(), + ) + + // Print error breakdown every 30 seconds + reportCount++ + if reportCount%6 == 0 && currentErrors > 0 { + errMu.Lock() + fmt.Printf(" Error breakdown:\n") + for k, v := range errorCounts { + fmt.Printf(" %s: %d\n", k, v) + } + errMu.Unlock() + } + + lastRequests = currentRequests + lastErrors = currentErrors + lastTime = now + } + } +} diff --git a/tools/pgimport/README.md b/tools/pgimport/README.md index f7352111..eff6ccad 100644 --- a/tools/pgimport/README.md +++ b/tools/pgimport/README.md @@ -22,13 +22,22 @@ pgimport \ --auth-file /var/lib/ntfy/user.db \ --web-push-file /var/lib/ntfy/webpush.db +# Using --create-schema to set up PostgreSQL schema automatically +pgimport \ + --create-schema \ + --database-url "postgres://user:pass@host:5432/ntfy?sslmode=require" \ + --cache-file /var/cache/ntfy/cache.db \ + --auth-file /var/lib/ntfy/user.db \ + --web-push-file /var/lib/ntfy/webpush.db + # Using server.yml (flags override config values) pgimport --config /etc/ntfy/server.yml ``` ## Prerequisites -- PostgreSQL schema must already be set up (run ntfy with `database-url` once) +- PostgreSQL schema must already be set up, either by running ntfy with `database-url` once, + or by passing `--create-schema` to pgimport to create the initial schema automatically - ntfy must not be running during the import - All three SQLite files are optional; only the ones specified will be imported diff --git a/tools/pgimport/main.go b/tools/pgimport/main.go index bf92a2a7..cbc171dd 100644 --- a/tools/pgimport/main.go +++ b/tools/pgimport/main.go @@ -23,6 +23,159 @@ const ( expectedMessageSchemaVersion = 14 expectedUserSchemaVersion = 6 expectedWebPushSchemaVersion = 1 + + everyoneID = "u_everyone" + + // Initial PostgreSQL schema for message store (from message/cache_postgres_schema.go) + createMessageSchemaQuery = ` + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_message_mid ON message (mid); + CREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id); + CREATE INDEX IF NOT EXISTS idx_message_topic_published_time ON message (topic, published, time, id); + CREATE INDEX IF NOT EXISTS idx_message_published_expires ON message (published, expires); + CREATE INDEX IF NOT EXISTS idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = ''; + CREATE INDEX IF NOT EXISTS idx_message_user_id_attachment_expires ON message (user_id, attachment_expires); + CREATE TABLE IF NOT EXISTS message_stats ( + key TEXT PRIMARY KEY, + value BIGINT + ); + INSERT INTO message_stats (key, value) VALUES ('messages', 0); + CREATE TABLE IF NOT EXISTS schema_version ( + store TEXT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO schema_version (store, version) VALUES ('message', 14); + ` + + // Initial PostgreSQL schema for user store (from user/manager_postgres_schema.go) + createUserSchemaQuery = ` + 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 ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT) + ON CONFLICT (id) DO NOTHING; + INSERT INTO schema_version (store, version) VALUES ('user', 6); + ` + + // Initial PostgreSQL schema for web push store (from webpush/store_postgres.go) + createWebPushSchemaQuery = ` + CREATE TABLE IF NOT EXISTS webpush_subscription ( + id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + key_auth TEXT NOT NULL, + key_p256dh TEXT NOT NULL, + user_id TEXT NOT NULL, + subscriber_ip TEXT NOT NULL, + updated_at BIGINT NOT NULL, + warned_at BIGINT NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip); + CREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription (updated_at); + CREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription (user_id); + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic); + CREATE TABLE IF NOT EXISTS schema_version ( + store TEXT PRIMARY KEY, + version INT NOT NULL + ); + INSERT INTO schema_version (store, version) VALUES ('webpush', 1); + ` ) var flags = []cli.Flag{ @@ -31,6 +184,7 @@ var flags = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file"}, Usage: "SQLite message cache file path"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file"}, Usage: "SQLite user/auth database file path"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, Usage: "SQLite web push database file path"}), + &cli.BoolFlag{Name: "create-schema", Usage: "create initial PostgreSQL schema before importing"}, } func main() { @@ -88,6 +242,12 @@ func execImport(c *cli.Context) error { } defer pgDB.Close() + if c.Bool("create-schema") { + if err := createSchema(pgDB, cacheFile, authFile, webPushFile); err != nil { + return fmt.Errorf("cannot create schema: %w", err) + } + } + if authFile != "" { if err := verifySchemaVersion(pgDB, "user", expectedUserSchemaVersion); err != nil { return err @@ -139,6 +299,34 @@ func execImport(c *cli.Context) error { return nil } +func createSchema(pgDB *sql.DB, cacheFile, authFile, webPushFile string) error { + fmt.Println("Creating initial PostgreSQL schema ...") + // User schema must be created before message schema, because message_stats and + // schema_version use "INSERT INTO" without "ON CONFLICT", so user schema (which + // also creates the schema_version table) must come first. + if authFile != "" { + fmt.Println(" Creating user schema ...") + if _, err := pgDB.Exec(createUserSchemaQuery); err != nil { + return fmt.Errorf("creating user schema: %w", err) + } + } + if cacheFile != "" { + fmt.Println(" Creating message schema ...") + if _, err := pgDB.Exec(createMessageSchemaQuery); err != nil { + return fmt.Errorf("creating message schema: %w", err) + } + } + if webPushFile != "" { + fmt.Println(" Creating web push schema ...") + if _, err := pgDB.Exec(createWebPushSchemaQuery); err != nil { + return fmt.Errorf("creating web push schema: %w", err) + } + } + fmt.Println(" Schema creation complete.") + fmt.Println() + return nil +} + func loadConfigFile(configFlag string, flags []cli.Flag) cli.BeforeFunc { return func(c *cli.Context) error { configFile := c.String(configFlag) diff --git a/user/manager.go b/user/manager.go index cd37cb3b..0ee6a6e1 100644 --- a/user/manager.go +++ b/user/manager.go @@ -422,33 +422,14 @@ func (a *Manager) UserByStripeCustomer(customerID string) (*User, error) { return a.readUser(rows) } -// Users returns a list of users +// Users returns a list of users. It loads all users in a single query +// rather than one query per user to avoid N+1 performance issues. func (a *Manager) Users() ([]*User, error) { - rows, err := a.db.Query(a.queries.selectUsernames) + rows, err := a.db.Query(a.queries.selectUsers) if err != nil { return nil, err } - defer rows.Close() - usernames := make([]string, 0) - for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - usernames = append(usernames, username) - } - rows.Close() - users := make([]*User, 0) - for _, username := range usernames { - user, err := a.User(username) - if err != nil { - return nil, err - } - users = append(users, user) - } - return users, nil + return a.readUsers(rows) } // UsersCount returns the number of users in the database @@ -470,14 +451,35 @@ func (a *Manager) UsersCount() (int64, error) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() + if !rows.Next() { + return nil, ErrUserNotFound + } + user, err := a.scanUser(rows) + if err != nil { + return nil, err + } + return user, nil +} + +func (a *Manager) readUsers(rows *sql.Rows) ([]*User, error) { + defer rows.Close() + users := make([]*User, 0) + for rows.Next() { + user, err := a.scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, nil +} + +func (a *Manager) scanUser(rows *sql.Rows) (*User, error) { var id, username, hash, role, prefs, syncTopic string var provisioned bool var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString var messages, emails, calls int64 var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 - if !rows.Next() { - return nil, ErrUserNotFound - } if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { @@ -1244,6 +1246,12 @@ func (a *Manager) maybeProvisionUsersAccessAndTokens() error { if !a.config.ProvisionEnabled { return nil } + // If there is nothing to provision, remove any previously provisioned items using + // cheap targeted queries, avoiding the expensive Users() call that loads all users. + if len(a.config.Users) == 0 && len(a.config.Access) == 0 && len(a.config.Tokens) == 0 { + return a.removeAllProvisioned() + } + // If there are provisioned users, do it the slow way existingUsers, err := a.Users() if err != nil { return err @@ -1269,6 +1277,23 @@ func (a *Manager) maybeProvisionUsersAccessAndTokens() error { }) } +// removeAllProvisioned removes all provisioned users, access entries, and tokens. This is the fast path +// for when there is nothing to provision, avoiding the expensive Users() call. +func (a *Manager) removeAllProvisioned() error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { + if _, err := tx.Exec(a.queries.deleteUserAccessProvisioned); err != nil { + return err + } + if _, err := tx.Exec(a.queries.deleteAllProvisionedTokens); err != nil { + return err + } + if _, err := tx.Exec(a.queries.deleteUsersProvisioned); err != nil { + return err + } + return nil + }) +} + // maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them. // It also removes users that are provisioned, but not in the config anymore. func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, existingUsers []*User) error { diff --git a/user/manager_postgres.go b/user/manager_postgres.go index bc8e3852..7138ae2c 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -7,6 +7,17 @@ import ( // PostgreSQL queries const ( // User queries + postgresSelectUsersQuery = ` + SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM "user" u + LEFT JOIN tier t on t.id = u.tier_id + ORDER BY + CASE u.role + WHEN 'admin' THEN 1 + WHEN 'anonymous' THEN 3 + ELSE 2 + END, u.user_name + ` postgresSelectUserByIDQuery = ` SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM "user" u @@ -56,6 +67,7 @@ const ( postgresDeleteUserQuery = `DELETE FROM "user" WHERE user_name = $1` postgresDeleteUserTierQuery = `UPDATE "user" SET tier_id = null WHERE user_name = $1` postgresDeleteUsersMarkedQuery = `DELETE FROM "user" WHERE deleted < $1` + postgresDeleteUsersProvisionedQuery = `DELETE FROM "user" WHERE provisioned = true` // Access queries postgresSelectTopicPermsQuery = ` @@ -146,13 +158,14 @@ const ( ON CONFLICT (user_id, token) DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned ` - postgresUpdateTokenQuery = `UPDATE user_token SET label = $1, expires = $2 WHERE user_id = $3 AND token = $4` - postgresUpdateTokenLastAccessQuery = `UPDATE user_token SET last_access = $1, last_origin = $2 WHERE token = $3` - postgresDeleteTokenQuery = `DELETE FROM user_token WHERE user_id = $1 AND token = $2` - postgresDeleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = $1` - postgresDeleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = $1` - postgresDeleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < $1` - postgresDeleteExcessTokensQuery = ` + postgresUpdateTokenQuery = `UPDATE user_token SET label = $1, expires = $2 WHERE user_id = $3 AND token = $4` + postgresUpdateTokenLastAccessQuery = `UPDATE user_token SET last_access = $1, last_origin = $2 WHERE token = $3` + postgresDeleteTokenQuery = `DELETE FROM user_token WHERE user_id = $1 AND token = $2` + postgresDeleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = $1` + postgresDeleteAllProvisionedTokensQuery = `DELETE FROM user_token WHERE provisioned = true` + postgresDeleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = $1` + postgresDeleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < $1` + postgresDeleteExcessTokensQuery = ` DELETE FROM user_token WHERE user_id = $1 AND (user_id, token) NOT IN ( @@ -210,6 +223,7 @@ var postgresQueries = queries{ selectUserByToken: postgresSelectUserByTokenQuery, selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery, selectUsernames: postgresSelectUsernamesQuery, + selectUsers: postgresSelectUsersQuery, selectUserCount: postgresSelectUserCountQuery, selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery, insertUser: postgresInsertUserQuery, @@ -224,6 +238,7 @@ var postgresQueries = queries{ deleteUser: postgresDeleteUserQuery, deleteUserTier: postgresDeleteUserTierQuery, deleteUsersMarked: postgresDeleteUsersMarkedQuery, + deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery, selectTopicPerms: postgresSelectTopicPermsQuery, selectUserAllAccess: postgresSelectUserAllAccessQuery, selectUserAccess: postgresSelectUserAccessQuery, @@ -246,6 +261,7 @@ var postgresQueries = queries{ updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, deleteToken: postgresDeleteTokenQuery, deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, + deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery, deleteAllToken: postgresDeleteAllTokenQuery, deleteExpiredTokens: postgresDeleteExpiredTokensQuery, deleteExcessTokens: postgresDeleteExcessTokensQuery, diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index e09c682c..b4068599 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -12,6 +12,17 @@ import ( const ( // User queries + sqliteSelectUsersQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + ORDER BY + CASE u.role + WHEN 'admin' THEN 1 + WHEN 'anonymous' THEN 3 + ELSE 2 + END, u.user + ` sqliteSelectUserByIDQuery = ` SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u @@ -61,6 +72,7 @@ const ( sqliteDeleteUserQuery = `DELETE FROM user WHERE user = ?` sqliteDeleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?` sqliteDeleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` + sqliteDeleteUsersProvisionedQuery = `DELETE FROM user WHERE provisioned = 1` // Access queries sqliteSelectTopicPermsQuery = ` @@ -144,13 +156,14 @@ const ( ON CONFLICT (user_id, token) DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned ` - sqliteUpdateTokenQuery = `UPDATE user_token SET label = ?, expires = ? WHERE user_id = ? AND token = ?` - sqliteUpdateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` - sqliteDeleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` - sqliteDeleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = ?` - sqliteDeleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` - sqliteDeleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` - sqliteDeleteExcessTokensQuery = ` + sqliteUpdateTokenQuery = `UPDATE user_token SET label = ?, expires = ? WHERE user_id = ? AND token = ?` + sqliteUpdateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` + sqliteDeleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` + sqliteDeleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = ?` + sqliteDeleteAllProvisionedTokensQuery = `DELETE FROM user_token WHERE provisioned = 1` + sqliteDeleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` + sqliteDeleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` + sqliteDeleteExcessTokensQuery = ` DELETE FROM user_token WHERE user_id = ? AND (user_id, token) NOT IN ( @@ -207,6 +220,7 @@ var sqliteQueries = queries{ selectUserByToken: sqliteSelectUserByTokenQuery, selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery, selectUsernames: sqliteSelectUsernamesQuery, + selectUsers: sqliteSelectUsersQuery, selectUserCount: sqliteSelectUserCountQuery, selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery, insertUser: sqliteInsertUserQuery, @@ -221,6 +235,7 @@ var sqliteQueries = queries{ deleteUser: sqliteDeleteUserQuery, deleteUserTier: sqliteDeleteUserTierQuery, deleteUsersMarked: sqliteDeleteUsersMarkedQuery, + deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery, selectTopicPerms: sqliteSelectTopicPermsQuery, selectUserAllAccess: sqliteSelectUserAllAccessQuery, selectUserAccess: sqliteSelectUserAccessQuery, @@ -243,6 +258,7 @@ var sqliteQueries = queries{ updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, deleteToken: sqliteDeleteTokenQuery, deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, + deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery, deleteAllToken: sqliteDeleteAllTokenQuery, deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, deleteExcessTokens: sqliteDeleteExcessTokensQuery, diff --git a/user/manager_test.go b/user/manager_test.go index c353acb8..53cae1d1 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1441,6 +1441,54 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { }) } +func TestManager_RemoveProvisionedOnEmptyConfig(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + // Start with provisioned users, access, and tokens + conf := &Config{ + DefaultAccess: PermissionReadWrite, + ProvisionEnabled: true, + BcryptCost: bcrypt.MinCost, + Users: []*User{ + {Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + Access: map[string][]*Grant{ + "provuser": { + {TopicPattern: "stats", Permission: PermissionReadWrite}, + }, + }, + Tokens: map[string][]*Token{ + "provuser": { + {Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Provisioned token"}, + }, + }, + } + a := newTestManagerFromConfig(t, newManager, conf) + + // Also add a manual (non-provisioned) user + require.Nil(t, a.AddUser("manualuser", "manual", RoleUser, false)) + + // Verify initial state + users, err := a.Users() + require.Nil(t, err) + require.Len(t, users, 3) // provuser, manualuser, everyone + + // Re-open with empty provisioning config (simulates config change) + require.Nil(t, a.Close()) + conf.Users = nil + conf.Access = nil + conf.Tokens = nil + a = newTestManagerFromConfig(t, newManager, conf) + + // Provisioned user should be removed, manual user should remain + users, err = a.Users() + require.Nil(t, err) + require.Len(t, users, 2) + require.Equal(t, "manualuser", users[0].Name) + require.False(t, users[0].Provisioned) + require.Equal(t, "*", users[1].Name) // everyone + }) +} + func TestToFromSQLWildcard(t *testing.T) { require.Equal(t, "up%", toSQLWildcard("up*")) require.Equal(t, "up\\_%", toSQLWildcard("up_*")) diff --git a/user/types.go b/user/types.go index e909ec78..08c65220 100644 --- a/user/types.go +++ b/user/types.go @@ -283,6 +283,7 @@ type queries struct { selectUserByToken string selectUserByStripeCustomerID string selectUsernames string + selectUsers string selectUserCount string selectUserIDFromUsername string insertUser string @@ -297,6 +298,7 @@ type queries struct { deleteUser string deleteUserTier string deleteUsersMarked string + deleteUsersProvisioned string // Access queries selectTopicPerms string @@ -323,6 +325,7 @@ type queries struct { updateTokenLastAccess string deleteToken string deleteProvisionedToken string + deleteAllProvisionedTokens string deleteAllToken string deleteExpiredTokens string deleteExcessTokens string From 0b189f65ff93b0f8df655a2ed50ceebb425b43bb Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 6 Mar 2026 15:52:53 -0500 Subject: [PATCH 271/548] Loadtest username/pass --- tools/loadtest/main.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tools/loadtest/main.go b/tools/loadtest/main.go index 1cfbdd56..be8a65fc 100644 --- a/tools/loadtest/main.go +++ b/tools/loadtest/main.go @@ -41,6 +41,8 @@ import ( var ( baseURL string + username string + password string rps float64 scale float64 numTopics int @@ -62,6 +64,8 @@ var ( func main() { flag.StringVar(&baseURL, "url", "https://staging.ntfy.sh", "Base URL of ntfy server") + flag.StringVar(&username, "user", "", "Username for authentication") + flag.StringVar(&password, "pass", "", "Password for authentication") flag.Float64Var(&rps, "rps", 71, "Target requests per second (default: prod average)") flag.Float64Var(&scale, "scale", 1.0, "Scale factor for all load (0.5 = half load, 2.0 = double)") flag.IntVar(&numTopics, "topics", 500, "Number of unique topics to use") @@ -222,6 +226,12 @@ func truncateErr(err error) string { return s } +func setAuth(req *http.Request) { + if username != "" && password != "" { + req.SetBasicAuth(username, password) + } +} + func generateTopics(n int) []string { topics := make([]string, n) for i := 0; i < n; i++ { @@ -289,6 +299,7 @@ func doPublishPost(ctx context.Context, client *http.Client, topics []string) { trackError("publish_post_req", err) return } + setAuth(req) // Some messages have titles/priorities like real traffic if mrand.Float32() < 0.3 { req.Header.Set("X-Title", "Load Test") @@ -317,6 +328,7 @@ func doPublishPut(ctx context.Context, client *http.Client, topics []string) { trackError("publish_put_req", err) return } + setAuth(req) resp, err := client.Do(req) totalRequests.Add(1) if err != nil { @@ -352,6 +364,7 @@ func doGet(ctx context.Context, client *http.Client, url string) { trackError("get_req", err) return } + setAuth(req) resp, err := client.Do(req) totalRequests.Add(1) if err != nil { @@ -379,6 +392,7 @@ func streamSubscription(ctx context.Context, client *http.Client, topics []strin time.Sleep(time.Second) continue } + setAuth(req) activeStreams.Add(1) resp, err := client.Do(req) if err != nil { @@ -432,8 +446,15 @@ func wsSubscription(ctx context.Context, topics []string) { dialer := websocket.Dialer{ HandshakeTimeout: 10 * time.Second, } + var wsHeader http.Header + if username != "" && password != "" { + wsHeader = http.Header{} + req, _ := http.NewRequest("GET", url, nil) + req.SetBasicAuth(username, password) + wsHeader.Set("Authorization", req.Header.Get("Authorization")) + } activeStreams.Add(1) - conn, _, err := dialer.DialContext(ctx, url, nil) + conn, _, err := dialer.DialContext(ctx, url, wsHeader) if err != nil { activeStreams.Add(-1) if ctx.Err() == nil { From d51465fb6a8923550dc7bedc6042b515391ee8e8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 7 Mar 2026 08:51:57 -0500 Subject: [PATCH 272/548] Release notes --- docs/releases.md | 60 +++++++++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index abc6b071..3e1e2970 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,48 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.17.0 | Feb 8, 2026 | -| ntfy Android app | v1.23.0 | Deb 22, 2026 | +| ntfy server | v2.18.0 | Mar 7, 2026 | +| ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +### ntfy server v2.18.0 +Released March 7, 2026 + +This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from +one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql-experimental). + +The code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents, +went through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests, +which took lots of evenings. + +I'll not instantly switch ntfy.sh over. Instead, I'm kindly asking the community to test the Postgres support and report back to me +if things are working (or not working). There is a one-off migration tool (entirely written by AI) that you can use to migrate. + +**Features:** + +* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting) + +**Bug fixes + maintenance:** + +* Preserve `
    ` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) + +### ntfy Android v1.24.0 +Released March 5, 2026 + +This is a tiny release that will revert the "reconnecting ..." behavior of the foreground notification. Lots of people +have complained about it, so I'm replacing it with a notification that shows up when the server connection has failed +for >15 minutes, hoping that people will be less annoyed by that. + +**Features:** + +* Show notification when connection to server has been lost for 15+ minutes, with dismiss, snooze and never-show-again actions + +**Bug fixes + maintenance:** + +* Fix crash in settings when fragment is detached during backup/restore or log operations + ## ntfy Android v1.23.0 Released February 22, 2026 @@ -1719,22 +1755,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.18.x (UNRELEASED) - -**Features:** - -* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114), thanks to [@brettinternet](https://github.com/brettinternet) for reporting) - -**Bug fixes + maintenance:** - -* Preserve `
    ` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) - -### ntfy Android v1.24.x (UNRELEASED) - -**Features:** - -* Show notification when connection to server has been lost for 15+ minutes, with dismiss, snooze and never-show-again actions - -**Bug fixes + maintenance:** - -* Fix crash in settings when fragment is detached during backup/restore or log operations +_None_ From 79c3ab9ecc697c9a64b3547813810d7a3ab6e948 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 7 Mar 2026 16:07:38 -0500 Subject: [PATCH 273/548] Bump version --- docs/install.md | 60 ++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/install.md b/docs/install.md index 9a81f963..a6e49174 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,37 +30,37 @@ deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.tar.gz - tar zxvf ntfy_2.17.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.17.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.tar.gz + tar zxvf ntfy_2.18.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.18.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.tar.gz - tar zxvf ntfy_2.17.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.17.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.tar.gz + tar zxvf ntfy_2.18.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.18.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.tar.gz - tar zxvf ntfy_2.17.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.17.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.tar.gz + tar zxvf ntfy_2.18.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.18.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.tar.gz - tar zxvf ntfy_2.17.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.17.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.tar.gz + tar zxvf ntfy_2.18.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.18.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -116,7 +116,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -124,7 +124,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -132,7 +132,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -140,7 +140,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -150,28 +150,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.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.17.0/ntfy_2.17.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.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.17.0/ntfy_2.17.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.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.17.0/ntfy_2.17.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -213,18 +213,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.17.0/ntfy_2.17.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.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.17.0/ntfy_2.17.0_darwin_all.tar.gz > ntfy_2.17.0_darwin_all.tar.gz -tar zxvf ntfy_2.17.0_darwin_all.tar.gz -sudo cp -a ntfy_2.17.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_darwin_all.tar.gz > ntfy_2.18.0_darwin_all.tar.gz +tar zxvf ntfy_2.18.0_darwin_all.tar.gz +sudo cp -a ntfy_2.18.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.17.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.18.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -245,7 +245,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.17.0/ntfy_2.17.0_windows_amd64.zip), +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.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` From fcf95dc9b84f305896119e09029406e58b817f64 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 7 Mar 2026 16:17:00 -0500 Subject: [PATCH 274/548] Fix release notes --- docs/releases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 3e1e2970..50da5ea8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -23,7 +23,7 @@ went through all queries multiple times and reviewed the logic over and over aga which took lots of evenings. I'll not instantly switch ntfy.sh over. Instead, I'm kindly asking the community to test the Postgres support and report back to me -if things are working (or not working). There is a one-off migration tool (entirely written by AI) that you can use to migrate. +if things are working (or not working). There is a [one-off migration tool](https://github.com/binwiederhier/ntfy/tree/main/tools/pgimport) (entirely written by AI) that you can use to migrate. **Features:** From 109271a93049e544d8b2e5c9cf4e6098700fa11c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Mar 2026 10:55:59 -0400 Subject: [PATCH 275/548] Avoid playing sound more than every 2s --- docs/releases.md | 10 +++++++--- web/src/app/Notifier.js | 10 +++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 50da5ea8..a3a077d7 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,7 +12,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release Please check out the release notes for [upcoming releases](#not-released-yet) below. -### ntfy server v2.18.0 +## ntfy server v2.18.0 Released March 7, 2026 This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from @@ -33,7 +33,7 @@ if things are working (or not working). There is a [one-off migration tool](http * Preserve `
    ` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) -### ntfy Android v1.24.0 +## ntfy Android v1.24.0 Released March 5, 2026 This is a tiny release that will revert the "reconnecting ..." behavior of the foreground notification. Lots of people @@ -1755,4 +1755,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -_None_ +### ntfy server v2.19.0 (UNRELEASED) + +**Bug fixes + maintenance:** + +* Throttle notification sound in web app to play at most once every 2 seconds diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 908df469..1c58d23c 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -8,6 +8,8 @@ import routes from "../components/routes"; * support this; most importantly, all iOS browsers do not support window.Notification. */ class Notifier { + lastSoundPlayedAt = 0; + async notify(subscription, notification) { if (!this.supported()) { return; @@ -49,11 +51,17 @@ class Notifier { } async playSound() { - // Play sound + // Play sound, but not more than once every 2 seconds + const now = Date.now(); + if (now - this.lastSoundPlayedAt < 2000) { + console.log(`[Notifier] Not playing notification sound, since it was last played <2s ago`, this.lastSoundPlayedAt); + return; + } const sound = await prefs.sound(); if (sound && sound !== "none") { try { await playSound(sound); + this.lastSoundPlayedAt = Date.now(); } catch (e) { console.log(`[Notifier] Error playing audio`, e); } From bcd07115c24faf7dc6074a876950070fa40bfeef Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Mar 2026 18:30:11 -0400 Subject: [PATCH 276/548] Add tooltips for edit/delete buttons --- docs/releases.md | 5 +++-- web/src/components/Account.jsx | 24 +++++++++++++--------- web/src/components/Preferences.jsx | 32 +++++++++++++++++++----------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index a3a077d7..15018d88 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1755,8 +1755,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.19.0 (UNRELEASED) +### ntfy server v2.19.x (UNRELEASED) **Bug fixes + maintenance:** -* Throttle notification sound in web app to play at most once every 2 seconds +* Web: Throttle notification sound in web app to play at most once every 2 seconds (similar to [#1550](https://github.com/binwiederhier/ntfy/issues/1550), thanks to [@jlaffaye](https://github.com/jlaffaye) for reporting) +* Web: Add hover tooltips to icon buttons in web app account and preferences pages ([#1565](https://github.com/binwiederhier/ntfy/issues/1565), thanks to [@jermanuts](https://github.com/jermanuts) for reporting) diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index bc5e3000..508d6de2 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -136,9 +136,11 @@ const ChangePassword = () => { ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ {!account?.provisioned ? ( - - - + + + + + ) : ( @@ -899,12 +901,16 @@ const TokensTable = (props) => { {token.token !== session.token() && !token.provisioned && ( <> - handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> - - - handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> - - + + handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> + + + + + handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> + + + )} {token.token === session.token() && ( diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index 3ef62189..093cc775 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -385,12 +385,16 @@ const UserTable = (props) => { {(!session.exists() || user.baseUrl !== config.base_url) && ( <> - handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> - - - handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> - - + + handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> + + + + + handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> + + + )} {session.exists() && user.baseUrl === config.base_url && ( @@ -738,12 +742,16 @@ const ReservationsTable = (props) => { /> )} - handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> - - - handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> - - + + handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> + + + + + handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> + + + ))} From 2b36ad9eb9d9d2714b50139a6ab746b6eb161c70 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Mar 2026 18:59:17 -0400 Subject: [PATCH 277/548] config generator --- docs/config-generator-plan.md | 164 +++++++++ docs/config.md | 239 +++++++++++++ docs/static/css/config-generator.css | 365 +++++++++++++++++++ docs/static/js/config-generator.js | 502 +++++++++++++++++++++++++++ mkdocs.yml | 2 + 5 files changed, 1272 insertions(+) create mode 100644 docs/config-generator-plan.md create mode 100644 docs/static/css/config-generator.css create mode 100644 docs/static/js/config-generator.js diff --git a/docs/config-generator-plan.md b/docs/config-generator-plan.md new file mode 100644 index 00000000..5fcd85e9 --- /dev/null +++ b/docs/config-generator-plan.md @@ -0,0 +1,164 @@ +# Config Generator for ntfy Docs + +## Context + +The ntfy config page (`docs/config.md`) documents 60+ server configuration options across many sections. Users often struggle to assemble a working config file. An interactive config generator helps users build a `server.yml`, `docker-compose.yml`, or env vars file by answering guided questions, with real-time preview. + +## Files Created + +| File | Purpose | +|------|---------| +| `docs/static/js/config-generator.js` | All generator logic (vanilla JS, ~300 lines) | +| `docs/static/css/config-generator.css` | All generator styles (~250 lines) | + +## Files Modified + +| File | Change | +|------|--------| +| `docs/config.md` | Inserted `## Config generator` HTML block at line 137 (before `## Database options`) | +| `mkdocs.yml` | Added `config-generator.js` to `extra_javascript` and `config-generator.css` to `extra_css` | + +## UI Layout + +Two-panel side-by-side layout: + +- **Left panel**: Collapsible accordion sections with form inputs for config options +- **Right panel** (sticky): Tabs for `server.yml` / `docker-compose.yml` / `Environment variables`, with a copy button. Updates in real-time as the user changes options. +- **Responsive**: Stacks vertically on screens < 900px + +## Configuration Sections (Left Panel) + +### 1. Basic Setup +- `base-url` (text, placeholder: `https://ntfy.example.com`) +- `listen-http` (text, placeholder: `:80`) +- `behind-proxy` (checkbox) + +### 2. Database +- Radio: SQLite (default) vs PostgreSQL +- SQLite → `cache-file` (text, placeholder: `/var/cache/ntfy/cache.db`) +- PostgreSQL → `database-url` (text, placeholder: `postgres://user:pass@host:5432/ntfy`) + +### 3. Access Control +- "Enable access control" (checkbox) → shows: + - `auth-file` (text, placeholder: `/var/lib/ntfy/auth.db`) — hidden if PostgreSQL + - `auth-default-access` (select: `read-write`, `read-only`, `write-only`, `deny-all`) + - `enable-login` (checkbox) + - `enable-signup` (checkbox) + - Provisioned users (repeatable rows): username, password hash, role (admin/user) + - Provisioned ACLs (repeatable rows): username, topic pattern, permission (rw/ro/wo/deny) + - Provisioned tokens (repeatable rows): username, token, label + +### 4. Attachments +- "Enable attachments" (checkbox) → shows: + - `attachment-cache-dir` (text, placeholder: `/var/cache/ntfy/attachments`) + - `attachment-file-size-limit` (text, placeholder: `15M`) + - `attachment-total-size-limit` (text, placeholder: `5G`) + - `attachment-expiry-duration` (text, placeholder: `3h`) + +### 5. Message Cache +- `cache-duration` (text, placeholder: `12h`) + +### 6. Web Push +- "Enable web push" (checkbox) → shows: + - `web-push-public-key` (text) + - `web-push-private-key` (text) + - `web-push-file` (text, placeholder: `/var/lib/ntfy/webpush.db`) — hidden if PostgreSQL + - `web-push-email-address` (text) + +### 7. Email Notifications (Outgoing) +- "Enable email sending" (checkbox) → shows: + - `smtp-sender-addr` (text, placeholder: `smtp.example.com:587`) + - `smtp-sender-from` (text, placeholder: `ntfy@example.com`) + - `smtp-sender-user` (text) + - `smtp-sender-pass` (text, type=password) + +### 8. Email Publishing (Incoming) +- "Enable email publishing" (checkbox) → shows: + - `smtp-server-listen` (text, placeholder: `:25`) + - `smtp-server-domain` (text, placeholder: `ntfy.example.com`) + - `smtp-server-addr-prefix` (text, placeholder: `ntfy-`) + +### 9. Upstream Server +- "iOS users will use this server" (checkbox) → sets `upstream-base-url: https://ntfy.sh` + +### 10. Monitoring +- "Enable Prometheus metrics" (checkbox) → sets `enable-metrics: true` + +## JavaScript Architecture (`config-generator.js`) + +Single vanilla JS file, no dependencies. Key structure: + +1. **Config definitions array (`CONFIG`)**: Each entry has `key`, `env`, `type`, `def`, `section`. This is the single source of truth for all three output formats. + +2. **`collectValues()`**: Reads all form inputs, returns an object of key→value, filtering out empty values. Handles repeatable rows for auth-users/access/tokens. Respects conditional visibility (skips hidden fields). + +3. **`generateServerYml(values)`**: Outputs YAML with section comments. Provisioned users/ACLs/tokens rendered as YAML arrays. + +4. **`generateDockerCompose(values)`**: Wraps values as env vars in Docker Compose format. Key transformations: + - Uses `NTFY_` prefixed env var names from config definitions + - File paths adjusted for Docker via `DOCKER_PATH_MAP` + - `$` doubled to `$$` in bcrypt hashes (with comment) + - Standard boilerplate: `services:`, `image:`, `volumes:`, `ports:`, `command: serve` + - Provisioned users/ACLs/tokens use indexed env vars (e.g., `NTFY_AUTH_USERS_0_USERNAME`) + +5. **`generateEnvVars(values)`**: Simple `export NTFY_KEY="value"` format. Single quotes for values containing `$`. + +6. **`updateOutput()`**: Called on every input change. Runs the active tab's generator and updates the `` element. + +7. **`initGenerator()`**: Called on DOMContentLoaded, no-ops if `#config-generator` is missing. Sets up event listeners for tabs, accordions, repeatable row add/remove, conditional toggles, and special checkboxes (upstream, metrics). + +### Conditional visibility +- Database = PostgreSQL → hide `cache-file`, `auth-file`, `web-push-file`; show `database-url` +- Access control unchecked → hide all auth fields +- Each "enable X" checkbox controls visibility of its section's detail fields via `data-toggle` attribute + +### Docker Compose path mapping +- `/var/cache/ntfy/cache.db` → `/var/lib/ntfy/cache.db` +- `/var/cache/ntfy/attachments` → `/var/lib/ntfy/attachments` +- `/var/lib/ntfy/*` → unchanged +- Volume: `./:/var/lib/ntfy` + +## CSS Architecture (`config-generator.css`) + +Key aspects: + +- **Flex layout** with `gap: 24px` for the two-panel layout +- **Sticky right panel**: `position: sticky; top: 76px; align-self: flex-start; max-height: calc(100vh - 100px); overflow-y: auto` +- **Dark mode**: Uses `body[data-md-color-scheme="slate"]` selectors (matching existing pattern in `extra.css`) +- **Accent color**: Uses `var(--md-primary-fg-color)` (`#338574`) for focus states, active tabs, and interactive elements +- **Responsive**: `@media (max-width: 900px)` → column layout, static right panel +- **Accordion**: `.cg-section-header` click toggles `.cg-section.open` which shows/hides `.cg-section-body` +- **Tabs**: `.cg-tab.active` gets accent color border-bottom highlight +- **Code output**: Dark background (`#1e1e1e`) with light text, consistent in both light and dark mode + +## HTML Block (in config.md) + +~250 lines of HTML inserted at line 137. Pure HTML (no markdown inside). Structure: + +``` +## Config generator +

    +
    + +
    +
    + +
    +
    +``` + +## Verification + +The docs build was verified with `mkdocs build` — completed successfully with no errors. The generated HTML at `server/docs/config/index.html` contains the config-generator elements. + +Manual verification checklist: +1. `cd ntfy && mkdocs serve` — open config page in browser +2. Verify all 10 sections expand/collapse +3. Fill in basic setup → check server.yml output updates +4. Switch tabs → verify docker-compose.yml and env vars render correctly +5. Toggle access control → verify auth fields show/hide +6. Add provisioned users/ACLs/tokens → verify repeatable rows work +7. Switch database to PostgreSQL → verify SQLite fields hide +8. Toggle dark mode → verify styles look correct +9. Resize to mobile width → verify column layout +10. Click copy button → verify clipboard content diff --git a/docs/config.md b/docs/config.md index a202cd95..d5939a14 100644 --- a/docs/config.md +++ b/docs/config.md @@ -135,6 +135,245 @@ using Docker Compose (i.e. `docker-compose.yml`): command: serve ``` +## Config generator + +Use this interactive tool to build your ntfy configuration. Select options below and copy the generated config. + +
    +
    +
    +
    Basic Setup
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    Database
    +
    +
    + + +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    Access Control
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    Attachments
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    Message Cache
    +
    +
    + + +
    +
    +
    +
    +
    Web Push
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    Email Notifications (Outgoing)
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    Email Publishing (Incoming)
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    Upstream Server
    +
    +
    + + +
    + +
    +
    +
    +
    Monitoring
    +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    server.yml
    +
    docker-compose.yml
    +
    Environment variables
    +
    +
    + +
    Configure options on the left to generate your config...
    +
    +
    +
    + ## Database options ntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions. You can choose between **SQLite** and **PostgreSQL** as the database backend. diff --git a/docs/static/css/config-generator.css b/docs/static/css/config-generator.css new file mode 100644 index 00000000..2d0ff062 --- /dev/null +++ b/docs/static/css/config-generator.css @@ -0,0 +1,365 @@ +/* Config Generator */ + +#config-generator-app { + display: flex; + gap: 24px; + margin: 1em 0 2em; + font-size: 0.82rem; +} + +#cg-left { + flex: 1; + min-width: 0; +} + +#cg-right { + width: 420px; + flex-shrink: 0; + position: sticky; + top: 76px; + align-self: flex-start; + max-height: calc(100vh - 100px); + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 8px; + background: #f8f9fa; +} + +/* Accordion sections */ +.cg-section { + border: 1px solid #ddd; + border-radius: 6px; + margin-bottom: 8px; + overflow: hidden; +} + +.cg-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + cursor: pointer; + font-weight: 500; + font-size: 0.88rem; + background: #f5f5f5; + user-select: none; + transition: background 0.15s; +} + +.cg-section-header:hover { + background: #eee; +} + +.cg-section-header::after { + content: '\25B6'; + font-size: 0.65em; + transition: transform 0.2s; +} + +.cg-section.open .cg-section-header::after { + transform: rotate(90deg); +} + +.cg-section-body { + display: none; + padding: 12px 14px; + border-top: 1px solid #ddd; +} + +.cg-section.open .cg-section-body { + display: block; +} + +/* Form fields */ +.cg-field { + margin-bottom: 10px; +} + +.cg-field:last-child { + margin-bottom: 0; +} + +.cg-field label { + display: block; + font-weight: 500; + margin-bottom: 3px; + font-size: 0.8rem; + color: #555; +} + +.cg-field input[type="text"], +.cg-field input[type="password"], +.cg-field select { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.82rem; + font-family: inherit; + box-sizing: border-box; + background: #fff; +} + +.cg-field input[type="text"]:focus, +.cg-field input[type="password"]:focus, +.cg-field select:focus { + border-color: var(--md-primary-fg-color); + outline: none; + box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15); +} + +.cg-checkbox { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.cg-checkbox input[type="checkbox"] { + accent-color: var(--md-primary-fg-color); +} + +.cg-checkbox label { + font-weight: 500; + font-size: 0.82rem; + margin: 0; + cursor: pointer; +} + +.cg-radio-group { + display: flex; + gap: 16px; + margin-bottom: 8px; +} + +.cg-radio-group label { + display: flex; + align-items: center; + gap: 4px; + font-weight: 400; + cursor: pointer; +} + +.cg-radio-group input[type="radio"] { + accent-color: var(--md-primary-fg-color); +} + +.cg-conditional { + display: none; + margin-top: 8px; +} + +.cg-conditional.visible { + display: block; +} + +/* Repeatable rows */ +.cg-repeatable-row { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.cg-repeatable-row input, +.cg-repeatable-row select { + flex: 1; + min-width: 80px; + padding: 5px 6px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.78rem; + font-family: inherit; + box-sizing: border-box; +} + +.cg-repeatable-row input:focus, +.cg-repeatable-row select:focus { + border-color: var(--md-primary-fg-color); + outline: none; +} + +.cg-btn-remove { + background: none; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + padding: 3px 7px; + color: #999; + line-height: 1; +} + +.cg-btn-remove:hover { + background: #fee; + border-color: #c66; + color: #c33; +} + +.cg-btn-add { + background: none; + border: 1px dashed #bbb; + border-radius: 4px; + cursor: pointer; + padding: 5px 10px; + font-size: 0.78rem; + color: #777; + margin-top: 2px; +} + +.cg-btn-add:hover { + border-color: var(--md-primary-fg-color); + color: var(--md-primary-fg-color); +} + +/* Tabs */ +.cg-tabs { + display: flex; + border-bottom: 2px solid #ddd; +} + +.cg-tab { + padding: 8px 14px; + cursor: pointer; + font-size: 0.78rem; + font-weight: 500; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + color: #777; + transition: color 0.15s, border-color 0.15s; + user-select: none; +} + +.cg-tab:hover { + color: #444; +} + +.cg-tab.active { + color: var(--md-primary-fg-color); + border-bottom-color: var(--md-primary-fg-color); +} + +/* Output panel */ +.cg-output-wrap { + position: relative; + padding: 12px; +} + +.cg-output-wrap pre { + margin: 0; + padding: 12px; + background: #1e1e1e; + color: #d4d4d4; + border-radius: 6px; + overflow-x: auto; + font-size: 0.76rem; + line-height: 1.5; + min-height: 120px; + white-space: pre; +} + +.cg-btn-copy { + position: absolute; + top: 18px; + right: 18px; + background: #444; + color: #ddd; + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 0.72rem; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.15s; +} + +.cg-btn-copy:hover { + opacity: 1; +} + +.cg-empty-msg { + color: #888; + font-style: italic; +} + +/* Dark mode */ +body[data-md-color-scheme="slate"] #cg-right { + background: #2e303e; + border-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-section { + border-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-section-header { + background: #2e303e; + color: #ccc; +} + +body[data-md-color-scheme="slate"] .cg-section-header:hover { + background: #363849; +} + +body[data-md-color-scheme="slate"] .cg-section-body { + border-top-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-field label { + color: #aaa; +} + +body[data-md-color-scheme="slate"] .cg-field input[type="text"], +body[data-md-color-scheme="slate"] .cg-field input[type="password"], +body[data-md-color-scheme="slate"] .cg-field select { + background: #1e1e2e; + border-color: #555; + color: #ddd; +} + +body[data-md-color-scheme="slate"] .cg-repeatable-row input, +body[data-md-color-scheme="slate"] .cg-repeatable-row select { + background: #1e1e2e; + border-color: #555; + color: #ddd; +} + +body[data-md-color-scheme="slate"] .cg-btn-remove { + border-color: #555; + color: #888; +} + +body[data-md-color-scheme="slate"] .cg-btn-add { + border-color: #555; + color: #888; +} + +body[data-md-color-scheme="slate"] .cg-tabs { + border-bottom-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-tab { + color: #888; +} + +body[data-md-color-scheme="slate"] .cg-tab:hover { + color: #bbb; +} + +body[data-md-color-scheme="slate"] .cg-output-wrap pre { + background: #161620; +} + +/* Responsive */ +@media (max-width: 900px) { + #config-generator-app { + flex-direction: column; + } + + #cg-right { + width: 100%; + position: static; + max-height: none; + } +} diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js new file mode 100644 index 00000000..4dc1fa69 --- /dev/null +++ b/docs/static/js/config-generator.js @@ -0,0 +1,502 @@ +// Config Generator for ntfy +(function () { + "use strict"; + + var CONFIG = [ + { key: "base-url", env: "NTFY_BASE_URL", section: "basic" }, + { key: "listen-http", env: "NTFY_LISTEN_HTTP", section: "basic", def: ":80" }, + { key: "behind-proxy", env: "NTFY_BEHIND_PROXY", section: "basic", type: "bool" }, + { key: "cache-file", env: "NTFY_CACHE_FILE", section: "database", def: "/var/cache/ntfy/cache.db" }, + { key: "database-url", env: "NTFY_DATABASE_URL", section: "database" }, + { key: "auth-file", env: "NTFY_AUTH_FILE", section: "auth", def: "/var/lib/ntfy/auth.db" }, + { key: "auth-default-access", env: "NTFY_AUTH_DEFAULT_ACCESS", section: "auth" }, + { key: "enable-login", env: "NTFY_ENABLE_LOGIN", section: "auth", type: "bool" }, + { key: "enable-signup", env: "NTFY_ENABLE_SIGNUP", section: "auth", type: "bool" }, + { key: "attachment-cache-dir", env: "NTFY_ATTACHMENT_CACHE_DIR", section: "attach", def: "/var/cache/ntfy/attachments" }, + { key: "attachment-file-size-limit", env: "NTFY_ATTACHMENT_FILE_SIZE_LIMIT", section: "attach", def: "15M" }, + { key: "attachment-total-size-limit", env: "NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT", section: "attach", def: "5G" }, + { key: "attachment-expiry-duration", env: "NTFY_ATTACHMENT_EXPIRY_DURATION", section: "attach", def: "3h" }, + { key: "cache-duration", env: "NTFY_CACHE_DURATION", section: "cache", def: "12h" }, + { key: "web-push-public-key", env: "NTFY_WEB_PUSH_PUBLIC_KEY", section: "webpush" }, + { key: "web-push-private-key", env: "NTFY_WEB_PUSH_PRIVATE_KEY", section: "webpush" }, + { key: "web-push-file", env: "NTFY_WEB_PUSH_FILE", section: "webpush", def: "/var/lib/ntfy/webpush.db" }, + { key: "web-push-email-address", env: "NTFY_WEB_PUSH_EMAIL_ADDRESS", section: "webpush" }, + { key: "smtp-sender-addr", env: "NTFY_SMTP_SENDER_ADDR", section: "smtp-out" }, + { key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" }, + { key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" }, + { key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, + { key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in", def: ":25" }, + { key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" }, + { key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, + { key: "upstream-base-url", env: "NTFY_UPSTREAM_BASE_URL", section: "upstream" }, + { key: "enable-metrics", env: "NTFY_ENABLE_METRICS", section: "metrics", type: "bool" }, + ]; + + var DOCKER_PATH_MAP = { + "/var/cache/ntfy/cache.db": "/var/lib/ntfy/cache.db", + "/var/cache/ntfy/attachments": "/var/lib/ntfy/attachments", + }; + + function collectValues() { + var values = {}; + var gen = document.getElementById("config-generator-app"); + if (!gen) return values; + + var isPostgres = gen.querySelector('input[name="cg-db-type"][value="postgres"]'); + isPostgres = isPostgres && isPostgres.checked; + + CONFIG.forEach(function (c) { + var el = gen.querySelector('[data-key="' + c.key + '"]'); + if (!el) return; + + // Skip hidden fields + var container = el.closest(".cg-conditional"); + if (container && !container.classList.contains("visible")) return; + var field = el.closest(".cg-field"); + if (field && field.style.display === "none") return; + + var val; + if (c.type === "bool") { + if (el.checked) val = "true"; + } else { + val = el.value.trim(); + if (!val) return; + } + if (val) values[c.key] = val; + }); + + // Provisioned users + var userRows = gen.querySelectorAll(".cg-auth-user-row"); + var users = []; + userRows.forEach(function (row) { + var u = row.querySelector('[data-field="username"]'); + var p = row.querySelector('[data-field="password"]'); + var r = row.querySelector('[data-field="role"]'); + if (u && p && u.value.trim() && p.value.trim()) { + users.push({ username: u.value.trim(), password: p.value.trim(), role: r ? r.value : "user" }); + } + }); + if (users.length) values["_auth-users"] = users; + + // Provisioned ACLs + var aclRows = gen.querySelectorAll(".cg-auth-acl-row"); + var acls = []; + aclRows.forEach(function (row) { + var u = row.querySelector('[data-field="username"]'); + var t = row.querySelector('[data-field="topic"]'); + var p = row.querySelector('[data-field="permission"]'); + if (u && t && t.value.trim()) { + acls.push({ user: u.value.trim(), topic: t.value.trim(), permission: p ? p.value : "read-write" }); + } + }); + if (acls.length) values["_auth-acls"] = acls; + + // Provisioned tokens + var tokenRows = gen.querySelectorAll(".cg-auth-token-row"); + var tokens = []; + tokenRows.forEach(function (row) { + var u = row.querySelector('[data-field="username"]'); + var t = row.querySelector('[data-field="token"]'); + var l = row.querySelector('[data-field="label"]'); + if (u && t && u.value.trim() && t.value.trim()) { + tokens.push({ user: u.value.trim(), token: t.value.trim(), label: l ? l.value.trim() : "" }); + } + }); + if (tokens.length) values["_auth-tokens"] = tokens; + + return values; + } + + function generateServerYml(values) { + var lines = []; + var sections = { + basic: "# Server", + database: "# Database", + auth: "# Access control", + attach: "# Attachments", + cache: "# Message cache", + webpush: "# Web push", + "smtp-out": "# Email notifications (outgoing)", + "smtp-in": "# Email publishing (incoming)", + upstream: "# Upstream", + metrics: "# Monitoring", + }; + var lastSection = ""; + + CONFIG.forEach(function (c) { + if (!(c.key in values)) return; + if (c.section !== lastSection) { + if (lines.length) lines.push(""); + if (sections[c.section]) lines.push(sections[c.section]); + lastSection = c.section; + } + var val = values[c.key]; + if (c.type === "bool") { + lines.push(c.key + ": true"); + } else { + lines.push(c.key + ': "' + val + '"'); + } + }); + + // Auth users + if (values["_auth-users"]) { + if (lastSection !== "auth") { lines.push(""); lines.push("# Access control"); } + lines.push("auth-users:"); + values["_auth-users"].forEach(function (u) { + lines.push(" - username: " + u.username); + lines.push(" password: " + u.password); + lines.push(" role: " + u.role); + }); + } + + // Auth ACLs + if (values["_auth-acls"]) { + lines.push("auth-default-access:"); + lines.push(" everyone:"); + values["_auth-acls"].forEach(function (a) { + // This uses the topic-level provisioning format + }); + // Actually use provisioned format + lines.pop(); lines.pop(); + lines.push("auth-access:"); + values["_auth-acls"].forEach(function (a) { + lines.push(" - user: " + (a.user || "*")); + lines.push(" topic: " + a.topic); + lines.push(" permission: " + a.permission); + }); + } + + // Auth tokens + if (values["_auth-tokens"]) { + lines.push("auth-tokens:"); + values["_auth-tokens"].forEach(function (t) { + lines.push(" - user: " + t.user); + lines.push(" token: " + t.token); + if (t.label) lines.push(" label: " + t.label); + }); + } + + return lines.join("\n"); + } + + function dockerPath(p) { + return DOCKER_PATH_MAP[p] || p; + } + + function generateDockerCompose(values) { + var lines = []; + lines.push("services:"); + lines.push(" ntfy:"); + lines.push(' image: binwiederhier/ntfy'); + lines.push(" command: serve"); + lines.push(" environment:"); + + CONFIG.forEach(function (c) { + if (!(c.key in values)) return; + var val = values[c.key]; + if (c.type === "bool") { + val = "true"; + } else { + // Adjust paths for Docker + val = dockerPath(val); + } + // Double $ in bcrypt hashes + if (val.indexOf("$") !== -1) { + val = val.replace(/\$/g, "$$$$"); + lines.push(" # Note: $ is doubled to $$ for docker-compose"); + } + lines.push(" " + c.env + ": " + val); + }); + + // Auth users in Docker + if (values["_auth-users"]) { + lines.push(" # Note: $ is doubled to $$ for docker-compose"); + values["_auth-users"].forEach(function (u, i) { + var pw = u.password.replace(/\$/g, "$$$$"); + lines.push(" NTFY_AUTH_USERS_" + i + "_USERNAME: " + u.username); + lines.push(" NTFY_AUTH_USERS_" + i + "_PASSWORD: " + pw); + lines.push(" NTFY_AUTH_USERS_" + i + "_ROLE: " + u.role); + }); + } + + // Auth ACLs in Docker + if (values["_auth-acls"]) { + values["_auth-acls"].forEach(function (a, i) { + lines.push(" NTFY_AUTH_ACCESS_" + i + "_USER: " + (a.user || "*")); + lines.push(" NTFY_AUTH_ACCESS_" + i + "_TOPIC: " + a.topic); + lines.push(" NTFY_AUTH_ACCESS_" + i + "_PERMISSION: " + a.permission); + }); + } + + // Auth tokens in Docker + if (values["_auth-tokens"]) { + values["_auth-tokens"].forEach(function (t, i) { + var tok = t.token.replace(/\$/g, "$$$$"); + lines.push(" NTFY_AUTH_TOKENS_" + i + "_USER: " + t.user); + lines.push(" NTFY_AUTH_TOKENS_" + i + "_TOKEN: " + tok); + if (t.label) lines.push(" NTFY_AUTH_TOKENS_" + i + "_LABEL: " + t.label); + }); + } + + lines.push(" volumes:"); + lines.push(" - ./:/var/lib/ntfy"); + lines.push(" ports:"); + + var listen = values["listen-http"] || ":80"; + var port = listen.replace(/.*:/, ""); + lines.push(' - "8080:' + port + '"'); + + return lines.join("\n"); + } + + function generateEnvVars(values) { + var lines = []; + + CONFIG.forEach(function (c) { + if (!(c.key in values)) return; + var val = values[c.key]; + if (c.type === "bool") val = "true"; + // Use single quotes if value contains $ + var q = val.indexOf("$") !== -1 ? "'" : '"'; + lines.push("export " + c.env + "=" + q + val + q); + }); + + if (values["_auth-users"]) { + values["_auth-users"].forEach(function (u, i) { + var q = u.password.indexOf("$") !== -1 ? "'" : '"'; + lines.push("export NTFY_AUTH_USERS_" + i + '_USERNAME="' + u.username + '"'); + lines.push("export NTFY_AUTH_USERS_" + i + "_PASSWORD=" + q + u.password + q); + lines.push("export NTFY_AUTH_USERS_" + i + '_ROLE="' + u.role + '"'); + }); + } + + if (values["_auth-acls"]) { + values["_auth-acls"].forEach(function (a, i) { + lines.push("export NTFY_AUTH_ACCESS_" + i + '_USER="' + (a.user || "*") + '"'); + lines.push("export NTFY_AUTH_ACCESS_" + i + '_TOPIC="' + a.topic + '"'); + lines.push("export NTFY_AUTH_ACCESS_" + i + '_PERMISSION="' + a.permission + '"'); + }); + } + + if (values["_auth-tokens"]) { + values["_auth-tokens"].forEach(function (t, i) { + var q = t.token.indexOf("$") !== -1 ? "'" : '"'; + lines.push("export NTFY_AUTH_TOKENS_" + i + '_USER="' + t.user + '"'); + lines.push("export NTFY_AUTH_TOKENS_" + i + "_TOKEN=" + q + t.token + q); + if (t.label) lines.push("export NTFY_AUTH_TOKENS_" + i + '_LABEL="' + t.label + '"'); + }); + } + + return lines.join("\n"); + } + + function updateOutput() { + var gen = document.getElementById("config-generator-app"); + if (!gen) return; + + var values = collectValues(); + var codeEl = gen.querySelector("#cg-code"); + if (!codeEl) return; + + var activeTab = gen.querySelector(".cg-tab.active"); + var format = activeTab ? activeTab.getAttribute("data-format") : "server-yml"; + + var output = ""; + var hasValues = false; + for (var k in values) { + if (values.hasOwnProperty(k)) { hasValues = true; break; } + } + + if (!hasValues) { + codeEl.innerHTML = 'Configure options on the left to generate your config...'; + return; + } + + if (format === "server-yml") { + output = generateServerYml(values); + } else if (format === "docker-compose") { + output = generateDockerCompose(values); + } else { + output = generateEnvVars(values); + } + + codeEl.textContent = output; + } + + function updateConditionalVisibility() { + var gen = document.getElementById("config-generator-app"); + if (!gen) return; + + var isPostgres = gen.querySelector('input[name="cg-db-type"][value="postgres"]'); + isPostgres = isPostgres && isPostgres.checked; + + // Database fields + var sqliteFields = gen.querySelector("#cg-sqlite-fields"); + var pgFields = gen.querySelector("#cg-postgres-fields"); + if (sqliteFields) sqliteFields.style.display = isPostgres ? "none" : "block"; + if (pgFields) pgFields.style.display = isPostgres ? "block" : "none"; + + // Hide auth-file and web-push-file if PostgreSQL + var authFile = gen.querySelector('[data-key="auth-file"]'); + if (authFile) { + var authField = authFile.closest(".cg-field"); + if (authField) authField.style.display = isPostgres ? "none" : ""; + } + var wpFile = gen.querySelector('[data-key="web-push-file"]'); + if (wpFile) { + var wpField = wpFile.closest(".cg-field"); + if (wpField) wpField.style.display = isPostgres ? "none" : ""; + } + + // Conditional sections (checkboxes that show/hide detail fields) + var toggles = gen.querySelectorAll("[data-toggle]"); + toggles.forEach(function (toggle) { + var target = gen.querySelector("#" + toggle.getAttribute("data-toggle")); + if (target) { + if (toggle.checked) { + target.classList.add("visible"); + } else { + target.classList.remove("visible"); + } + } + }); + } + + function addRepeatableRow(container, type) { + var row = document.createElement("div"); + row.className = "cg-repeatable-row cg-auth-" + type + "-row"; + + if (type === "user") { + row.innerHTML = + '' + + '' + + '' + + ''; + } else if (type === "acl") { + row.innerHTML = + '' + + '' + + '' + + ''; + } else if (type === "token") { + row.innerHTML = + '' + + '' + + '' + + ''; + } + + row.querySelector(".cg-btn-remove").addEventListener("click", function () { + row.remove(); + updateOutput(); + }); + row.querySelectorAll("input, select").forEach(function (el) { + el.addEventListener("input", updateOutput); + }); + + container.appendChild(row); + } + + function initGenerator() { + var gen = document.getElementById("config-generator-app"); + if (!gen) return; + + // Accordion toggle + gen.querySelectorAll(".cg-section-header").forEach(function (header) { + header.addEventListener("click", function () { + header.parentElement.classList.toggle("open"); + }); + }); + + // Open first section by default + var first = gen.querySelector(".cg-section"); + if (first) first.classList.add("open"); + + // Tab switching + gen.querySelectorAll(".cg-tab").forEach(function (tab) { + tab.addEventListener("click", function () { + gen.querySelectorAll(".cg-tab").forEach(function (t) { t.classList.remove("active"); }); + tab.classList.add("active"); + updateOutput(); + }); + }); + + // All form inputs trigger update + gen.querySelectorAll("input, select").forEach(function (el) { + var evt = (el.type === "checkbox" || el.type === "radio") ? "change" : "input"; + el.addEventListener(evt, function () { + updateConditionalVisibility(); + updateOutput(); + }); + }); + + // Conditional toggles + gen.querySelectorAll("[data-toggle]").forEach(function (toggle) { + toggle.addEventListener("change", function () { + updateConditionalVisibility(); + updateOutput(); + }); + }); + + // Database radio + gen.querySelectorAll('input[name="cg-db-type"]').forEach(function (r) { + r.addEventListener("change", function () { + updateConditionalVisibility(); + updateOutput(); + }); + }); + + // Add buttons for repeatable rows + gen.querySelectorAll(".cg-btn-add").forEach(function (btn) { + btn.addEventListener("click", function () { + var type = btn.getAttribute("data-add-type"); + var container = btn.previousElementSibling; + if (!container) container = btn.parentElement.querySelector(".cg-repeatable-container"); + addRepeatableRow(container, type); + }); + }); + + // Copy button + var copyBtn = gen.querySelector("#cg-copy-btn"); + if (copyBtn) { + copyBtn.addEventListener("click", function () { + var code = gen.querySelector("#cg-code"); + if (code && code.textContent) { + navigator.clipboard.writeText(code.textContent).then(function () { + copyBtn.textContent = "Copied!"; + setTimeout(function () { copyBtn.textContent = "Copy"; }, 2000); + }); + } + }); + } + + // Upstream checkbox special handling + var upstreamCheck = gen.querySelector("#cg-upstream-check"); + if (upstreamCheck) { + upstreamCheck.addEventListener("change", function () { + var input = gen.querySelector('[data-key="upstream-base-url"]'); + if (input) input.value = upstreamCheck.checked ? "https://ntfy.sh" : ""; + updateOutput(); + }); + } + + // Metrics checkbox special handling + var metricsCheck = gen.querySelector("#cg-metrics-check"); + if (metricsCheck) { + metricsCheck.addEventListener("change", function () { + var input = gen.querySelector('[data-key="enable-metrics"]'); + if (input) input.checked = metricsCheck.checked; + updateOutput(); + }); + } + + updateConditionalVisibility(); + updateOutput(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initGenerator); + } else { + initGenerator(); + } +})(); diff --git a/mkdocs.yml b/mkdocs.yml index f2a7afb7..022ba03e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,8 +42,10 @@ extra: link: https://github.com/binwiederhier extra_javascript: - static/js/extra.js + - static/js/config-generator.js extra_css: - static/css/extra.css + - static/css/config-generator.css markdown_extensions: - admonition From 612afb143544efd2e17694a984bc326a50d6553d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Mar 2026 19:32:54 -0400 Subject: [PATCH 278/548] Configurator --- docs/config.md | 155 ++++++--------- docs/static/css/config-generator.css | 222 +++++++++++++-------- docs/static/js/config-generator.js | 276 +++++++++++++++------------ 3 files changed, 352 insertions(+), 301 deletions(-) diff --git a/docs/config.md b/docs/config.md index d5939a14..2fba771d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -141,52 +141,61 @@ Use this interactive tool to build your ntfy configuration. Select options below
    -
    -
    Basic Setup
    -
    -
    - - +
    +
    + +
    -
    - - -
    -
    - - +
    + +
    +
    +
    + +
    + +
    -
    -
    Database
    -
    +
    +
    + +
    + + + + + + + +
    +
    + +
    +
    +
    Server
    - - + +
    - -
    -
    -
    Access Control
    -
    -
    - - -
    -
    + + -
    -
    Attachments
    -
    -
    - - -
    -
    + -
    -
    -
    -
    Message Cache
    -
    -
    - - -
    -
    -
    -
    -
    Web Push
    -
    -
    - - -
    -
    + -
    -
    -
    -
    Email Notifications (Outgoing)
    -
    -
    - - -
    -
    + -
    -
    -
    -
    Email Publishing (Incoming)
    -
    -
    - - -
    -
    + -
    -
    -
    -
    Upstream Server
    -
    -
    - - -
    -
    -
    -
    -
    Monitoring
    -
    -
    - - -
    -
    server.yml
    docker-compose.yml
    -
    Environment variables
    +
    -
    Configure options on the left to generate your config...
    diff --git a/docs/static/css/config-generator.css b/docs/static/css/config-generator.css index 2d0ff062..88d541ab 100644 --- a/docs/static/css/config-generator.css +++ b/docs/static/css/config-generator.css @@ -4,7 +4,7 @@ display: flex; gap: 24px; margin: 1em 0 2em; - font-size: 0.82rem; + font-size: 0.75rem; } #cg-left { @@ -13,7 +13,8 @@ } #cg-right { - width: 420px; + flex: 1; + min-width: 0; flex-shrink: 0; position: sticky; top: 76px; @@ -25,49 +26,115 @@ background: #f8f9fa; } -/* Accordion sections */ -.cg-section { +/* Wizard questions */ +.cg-wizard { border: 1px solid #ddd; - border-radius: 6px; - margin-bottom: 8px; - overflow: hidden; + border-radius: 8px; + padding: 16px 18px; + margin-bottom: 16px; + background: #f8f9fa; } -.cg-section-header { +.cg-wizard-step { + margin-bottom: 16px; +} + +.cg-wizard-step:last-child { + margin-bottom: 0; +} + +.cg-wizard-label { + display: block; + font-weight: 600; + font-size: 0.78rem; + margin-bottom: 6px; + color: #333; +} + +.cg-wizard-input { + width: 100%; + padding: 7px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.82rem; + font-family: inherit; + box-sizing: border-box; + background: #fff; +} + +.cg-wizard-input:focus { + border-color: var(--md-primary-fg-color); + outline: none; + box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15); +} + +.cg-wizard-toggle label { display: flex; align-items: center; - justify-content: space-between; - padding: 10px 14px; + gap: 6px; + font-size: 0.82rem; cursor: pointer; - font-weight: 500; - font-size: 0.88rem; - background: #f5f5f5; - user-select: none; - transition: background 0.15s; } -.cg-section-header:hover { - background: #eee; +.cg-wizard-toggle input[type="checkbox"] { + accent-color: var(--md-primary-fg-color); } -.cg-section-header::after { - content: '\25B6'; - font-size: 0.65em; - transition: transform 0.2s; +.cg-radio-group { + display: flex; + gap: 16px; + flex-wrap: wrap; } -.cg-section.open .cg-section-header::after { - transform: rotate(90deg); +.cg-radio-group label { + display: flex; + align-items: center; + gap: 4px; + font-weight: 400; + font-size: 0.82rem; + cursor: pointer; } -.cg-section-body { - display: none; +.cg-radio-group input[type="radio"] { + accent-color: var(--md-primary-fg-color); +} + +.cg-feature-grid { + display: flex; + flex-direction: column; + gap: 5px; +} + +.cg-feature-grid label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + cursor: pointer; +} + +.cg-feature-grid input[type="checkbox"] { + accent-color: var(--md-primary-fg-color); +} + +/* Detail sections */ +#cg-details { + display: flex; + flex-direction: column; + gap: 12px; +} + +.cg-detail-section { + border: 1px solid #ddd; + border-radius: 6px; padding: 12px 14px; - border-top: 1px solid #ddd; } -.cg-section.open .cg-section-body { - display: block; +.cg-detail-heading { + font-weight: 600; + font-size: 0.78rem; + margin-bottom: 10px; + color: var(--md-primary-fg-color); } /* Form fields */ @@ -126,33 +193,6 @@ cursor: pointer; } -.cg-radio-group { - display: flex; - gap: 16px; - margin-bottom: 8px; -} - -.cg-radio-group label { - display: flex; - align-items: center; - gap: 4px; - font-weight: 400; - cursor: pointer; -} - -.cg-radio-group input[type="radio"] { - accent-color: var(--md-primary-fg-color); -} - -.cg-conditional { - display: none; - margin-top: 8px; -} - -.cg-conditional.visible { - display: block; -} - /* Repeatable rows */ .cg-repeatable-row { display: flex; @@ -242,15 +282,15 @@ /* Output panel */ .cg-output-wrap { - position: relative; padding: 12px; } .cg-output-wrap pre { margin: 0; padding: 12px; - background: #1e1e1e; - color: #d4d4d4; + background: #f5f5f5; + color: #333; + border: 1px solid #ddd; border-radius: 6px; overflow-x: auto; font-size: 0.76rem; @@ -260,22 +300,23 @@ } .cg-btn-copy { - position: absolute; - top: 18px; - right: 18px; - background: #444; - color: #ddd; + margin-left: auto; + background: none; + color: #777; border: none; - border-radius: 4px; - padding: 4px 10px; - font-size: 0.72rem; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + padding: 8px 10px; cursor: pointer; - opacity: 0.8; - transition: opacity 0.15s; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; } .cg-btn-copy:hover { - opacity: 1; + color: #333; } .cg-empty-msg { @@ -289,21 +330,33 @@ body[data-md-color-scheme="slate"] #cg-right { border-color: #444; } -body[data-md-color-scheme="slate"] .cg-section { +body[data-md-color-scheme="slate"] .cg-wizard { + background: #2e303e; border-color: #444; } -body[data-md-color-scheme="slate"] .cg-section-header { - background: #2e303e; +body[data-md-color-scheme="slate"] .cg-wizard-label { color: #ccc; } -body[data-md-color-scheme="slate"] .cg-section-header:hover { - background: #363849; +body[data-md-color-scheme="slate"] .cg-wizard-input { + background: #1e1e2e; + border-color: #555; + color: #ddd; } -body[data-md-color-scheme="slate"] .cg-section-body { - border-top-color: #444; +body[data-md-color-scheme="slate"] .cg-wizard-toggle label, +body[data-md-color-scheme="slate"] .cg-radio-group label, +body[data-md-color-scheme="slate"] .cg-feature-grid label { + color: #ccc; +} + +body[data-md-color-scheme="slate"] .cg-detail-section { + border-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-detail-heading { + color: var(--md-primary-fg-color); } body[data-md-color-scheme="slate"] .cg-field label { @@ -348,7 +401,21 @@ body[data-md-color-scheme="slate"] .cg-tab:hover { } body[data-md-color-scheme="slate"] .cg-output-wrap pre { - background: #161620; + background: #1e1e2e; + color: #ddd; + border-color: #444; +} + +body[data-md-color-scheme="slate"] .cg-btn-copy { + color: #777; +} + +body[data-md-color-scheme="slate"] .cg-btn-copy:hover { + color: #bbb; +} + +body[data-md-color-scheme="slate"] .cg-checkbox label { + color: #ccc; } /* Responsive */ @@ -362,4 +429,5 @@ body[data-md-color-scheme="slate"] .cg-output-wrap pre { position: static; max-height: none; } + } diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index 4dc1fa69..87ef0ab2 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -6,7 +6,6 @@ { key: "base-url", env: "NTFY_BASE_URL", section: "basic" }, { key: "listen-http", env: "NTFY_LISTEN_HTTP", section: "basic", def: ":80" }, { key: "behind-proxy", env: "NTFY_BEHIND_PROXY", section: "basic", type: "bool" }, - { key: "cache-file", env: "NTFY_CACHE_FILE", section: "database", def: "/var/cache/ntfy/cache.db" }, { key: "database-url", env: "NTFY_DATABASE_URL", section: "database" }, { key: "auth-file", env: "NTFY_AUTH_FILE", section: "auth", def: "/var/lib/ntfy/auth.db" }, { key: "auth-default-access", env: "NTFY_AUTH_DEFAULT_ACCESS", section: "auth" }, @@ -16,6 +15,7 @@ { key: "attachment-file-size-limit", env: "NTFY_ATTACHMENT_FILE_SIZE_LIMIT", section: "attach", def: "15M" }, { key: "attachment-total-size-limit", env: "NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT", section: "attach", def: "5G" }, { key: "attachment-expiry-duration", env: "NTFY_ATTACHMENT_EXPIRY_DURATION", section: "attach", def: "3h" }, + { key: "cache-file", env: "NTFY_CACHE_FILE", section: "cache", def: "/var/cache/ntfy/cache.db" }, { key: "cache-duration", env: "NTFY_CACHE_DURATION", section: "cache", def: "12h" }, { key: "web-push-public-key", env: "NTFY_WEB_PUSH_PUBLIC_KEY", section: "webpush" }, { key: "web-push-private-key", env: "NTFY_WEB_PUSH_PRIVATE_KEY", section: "webpush" }, @@ -37,21 +37,29 @@ "/var/cache/ntfy/attachments": "/var/lib/ntfy/attachments", }; + // Feature checkbox ID → detail section ID + var FEATURE_MAP = { + "cg-feat-cache": "cg-detail-cache", + "cg-feat-attach": "cg-detail-attach", + "cg-feat-webpush": "cg-detail-webpush", + "cg-feat-smtp-out": "cg-detail-smtp-out", + "cg-feat-smtp-in": "cg-detail-smtp-in", + }; + function collectValues() { var values = {}; var gen = document.getElementById("config-generator-app"); if (!gen) return values; - var isPostgres = gen.querySelector('input[name="cg-db-type"][value="postgres"]'); - isPostgres = isPostgres && isPostgres.checked; - CONFIG.forEach(function (c) { var el = gen.querySelector('[data-key="' + c.key + '"]'); if (!el) return; - // Skip hidden fields - var container = el.closest(".cg-conditional"); - if (container && !container.classList.contains("visible")) return; + // Skip fields in hidden detail sections + var section = el.closest(".cg-detail-section"); + if (section && section.style.display === "none") return; + + // Skip hidden individual fields (e.g. auth-file when using PostgreSQL) var field = el.closest(".cg-field"); if (field && field.style.display === "none") return; @@ -151,13 +159,6 @@ // Auth ACLs if (values["_auth-acls"]) { - lines.push("auth-default-access:"); - lines.push(" everyone:"); - values["_auth-acls"].forEach(function (a) { - // This uses the topic-level provisioning format - }); - // Actually use provisioned format - lines.pop(); lines.pop(); lines.push("auth-access:"); values["_auth-acls"].forEach(function (a) { lines.push(" - user: " + (a.user || "*")); @@ -249,47 +250,6 @@ return lines.join("\n"); } - function generateEnvVars(values) { - var lines = []; - - CONFIG.forEach(function (c) { - if (!(c.key in values)) return; - var val = values[c.key]; - if (c.type === "bool") val = "true"; - // Use single quotes if value contains $ - var q = val.indexOf("$") !== -1 ? "'" : '"'; - lines.push("export " + c.env + "=" + q + val + q); - }); - - if (values["_auth-users"]) { - values["_auth-users"].forEach(function (u, i) { - var q = u.password.indexOf("$") !== -1 ? "'" : '"'; - lines.push("export NTFY_AUTH_USERS_" + i + '_USERNAME="' + u.username + '"'); - lines.push("export NTFY_AUTH_USERS_" + i + "_PASSWORD=" + q + u.password + q); - lines.push("export NTFY_AUTH_USERS_" + i + '_ROLE="' + u.role + '"'); - }); - } - - if (values["_auth-acls"]) { - values["_auth-acls"].forEach(function (a, i) { - lines.push("export NTFY_AUTH_ACCESS_" + i + '_USER="' + (a.user || "*") + '"'); - lines.push("export NTFY_AUTH_ACCESS_" + i + '_TOPIC="' + a.topic + '"'); - lines.push("export NTFY_AUTH_ACCESS_" + i + '_PERMISSION="' + a.permission + '"'); - }); - } - - if (values["_auth-tokens"]) { - values["_auth-tokens"].forEach(function (t, i) { - var q = t.token.indexOf("$") !== -1 ? "'" : '"'; - lines.push("export NTFY_AUTH_TOKENS_" + i + '_USER="' + t.user + '"'); - lines.push("export NTFY_AUTH_TOKENS_" + i + "_TOKEN=" + q + t.token + q); - if (t.label) lines.push("export NTFY_AUTH_TOKENS_" + i + '_LABEL="' + t.label + '"'); - }); - } - - return lines.join("\n"); - } - function updateOutput() { var gen = document.getElementById("config-generator-app"); if (!gen) return; @@ -301,7 +261,6 @@ var activeTab = gen.querySelector(".cg-tab.active"); var format = activeTab ? activeTab.getAttribute("data-format") : "server-yml"; - var output = ""; var hasValues = false; for (var k in values) { if (values.hasOwnProperty(k)) { hasValues = true; break; } @@ -312,29 +271,69 @@ return; } - if (format === "server-yml") { - output = generateServerYml(values); - } else if (format === "docker-compose") { + var output = ""; + if (format === "docker-compose") { output = generateDockerCompose(values); } else { - output = generateEnvVars(values); + output = generateServerYml(values); } codeEl.textContent = output; } - function updateConditionalVisibility() { + // Set a field's value only if it is currently empty + function prefill(gen, key, value) { + var el = gen.querySelector('[data-key="' + key + '"]'); + if (el && !el.value.trim()) el.value = value; + } + + // Set a select's value (always, to reflect wizard state) + function prefillSelect(gen, key, value) { + var el = gen.querySelector('[data-key="' + key + '"]'); + if (el) el.value = value; + } + + function updateVisibility() { var gen = document.getElementById("config-generator-app"); if (!gen) return; var isPostgres = gen.querySelector('input[name="cg-db-type"][value="postgres"]'); isPostgres = isPostgres && isPostgres.checked; - // Database fields - var sqliteFields = gen.querySelector("#cg-sqlite-fields"); - var pgFields = gen.querySelector("#cg-postgres-fields"); - if (sqliteFields) sqliteFields.style.display = isPostgres ? "none" : "block"; - if (pgFields) pgFields.style.display = isPostgres ? "block" : "none"; + var isPrivate = gen.querySelector('input[name="cg-server-type"][value="private"]'); + isPrivate = isPrivate && isPrivate.checked; + + var cacheEnabled = gen.querySelector("#cg-feat-cache"); + cacheEnabled = cacheEnabled && cacheEnabled.checked; + + var attachEnabled = gen.querySelector("#cg-feat-attach"); + attachEnabled = attachEnabled && attachEnabled.checked; + + var webpushEnabled = gen.querySelector("#cg-feat-webpush"); + webpushEnabled = webpushEnabled && webpushEnabled.checked; + + var smtpOutEnabled = gen.querySelector("#cg-feat-smtp-out"); + smtpOutEnabled = smtpOutEnabled && smtpOutEnabled.checked; + + var smtpInEnabled = gen.querySelector("#cg-feat-smtp-in"); + smtpInEnabled = smtpInEnabled && smtpInEnabled.checked; + + // Show database question only if a DB-dependent feature is selected + var needsDb = isPrivate || cacheEnabled || webpushEnabled; + var dbStep = gen.querySelector("#cg-wizard-db"); + if (dbStep) dbStep.style.display = needsDb ? "" : "none"; + + // Database detail section (PostgreSQL only; SQLite needs no extra config) + var pgSection = gen.querySelector("#cg-detail-db-postgres"); + if (pgSection) pgSection.style.display = (needsDb && isPostgres) ? "" : "none"; + + // Hide cache-file in message cache section when PostgreSQL + var cacheFileField = gen.querySelector("#cg-cache-file-field"); + if (cacheFileField) cacheFileField.style.display = isPostgres ? "none" : ""; + + // Auth detail section + var authSection = gen.querySelector("#cg-detail-auth"); + if (authSection) authSection.style.display = isPrivate ? "" : "none"; // Hide auth-file and web-push-file if PostgreSQL var authFile = gen.querySelector('[data-key="auth-file"]'); @@ -348,18 +347,75 @@ if (wpField) wpField.style.display = isPostgres ? "none" : ""; } - // Conditional sections (checkboxes that show/hide detail fields) - var toggles = gen.querySelectorAll("[data-toggle]"); - toggles.forEach(function (toggle) { - var target = gen.querySelector("#" + toggle.getAttribute("data-toggle")); - if (target) { - if (toggle.checked) { - target.classList.add("visible"); - } else { - target.classList.remove("visible"); - } + // Feature toggles → detail sections + for (var featId in FEATURE_MAP) { + var checkbox = gen.querySelector("#" + featId); + var section = gen.querySelector("#" + FEATURE_MAP[featId]); + if (checkbox && section) { + section.style.display = checkbox.checked ? "" : "none"; } - }); + } + + // Upstream special handling + var upstreamCheck = gen.querySelector("#cg-feat-upstream"); + var upstreamInput = gen.querySelector('[data-key="upstream-base-url"]'); + if (upstreamCheck && upstreamInput) { + upstreamInput.value = upstreamCheck.checked ? "https://ntfy.sh" : ""; + } + + // Metrics special handling + var metricsCheck = gen.querySelector("#cg-feat-metrics"); + var metricsInput = gen.querySelector('[data-key="enable-metrics"]'); + if (metricsCheck && metricsInput) { + metricsInput.checked = metricsCheck.checked; + } + + // --- Pre-fill defaults based on wizard selections --- + + // Database + if (isPostgres) { + prefill(gen, "database-url", "postgres://user:pass@host:5432/ntfy"); + } + + // Access control: always sync default-access with open/private + if (isPrivate) { + prefillSelect(gen, "auth-default-access", "deny-all"); + if (!isPostgres) prefill(gen, "auth-file", "/var/lib/ntfy/auth.db"); + } else { + prefillSelect(gen, "auth-default-access", "read-write"); + } + + // Persistent message cache + if (cacheEnabled) { + if (!isPostgres) prefill(gen, "cache-file", "/var/cache/ntfy/cache.db"); + prefill(gen, "cache-duration", "12h"); + } + + // Attachments + if (attachEnabled) { + prefill(gen, "attachment-cache-dir", "/var/cache/ntfy/attachments"); + prefill(gen, "attachment-file-size-limit", "15M"); + prefill(gen, "attachment-total-size-limit", "5G"); + prefill(gen, "attachment-expiry-duration", "3h"); + } + + // Web push + if (webpushEnabled) { + if (!isPostgres) prefill(gen, "web-push-file", "/var/lib/ntfy/webpush.db"); + prefill(gen, "web-push-email-address", "admin@example.com"); + } + + // Email notifications (outgoing) + if (smtpOutEnabled) { + prefill(gen, "smtp-sender-addr", "smtp.example.com:587"); + prefill(gen, "smtp-sender-from", "ntfy@example.com"); + } + + // Email publishing (incoming) + if (smtpInEnabled) { + prefill(gen, "smtp-server-listen", ":25"); + prefill(gen, "smtp-server-domain", "ntfy.example.com"); + } } function addRepeatableRow(container, type) { @@ -401,17 +457,6 @@ var gen = document.getElementById("config-generator-app"); if (!gen) return; - // Accordion toggle - gen.querySelectorAll(".cg-section-header").forEach(function (header) { - header.addEventListener("click", function () { - header.parentElement.classList.toggle("open"); - }); - }); - - // Open first section by default - var first = gen.querySelector(".cg-section"); - if (first) first.classList.add("open"); - // Tab switching gen.querySelectorAll(".cg-tab").forEach(function (tab) { tab.addEventListener("click", function () { @@ -425,23 +470,7 @@ gen.querySelectorAll("input, select").forEach(function (el) { var evt = (el.type === "checkbox" || el.type === "radio") ? "change" : "input"; el.addEventListener(evt, function () { - updateConditionalVisibility(); - updateOutput(); - }); - }); - - // Conditional toggles - gen.querySelectorAll("[data-toggle]").forEach(function (toggle) { - toggle.addEventListener("change", function () { - updateConditionalVisibility(); - updateOutput(); - }); - }); - - // Database radio - gen.querySelectorAll('input[name="cg-db-type"]').forEach(function (r) { - r.addEventListener("change", function () { - updateConditionalVisibility(); + updateVisibility(); updateOutput(); }); }); @@ -459,38 +488,33 @@ // Copy button var copyBtn = gen.querySelector("#cg-copy-btn"); if (copyBtn) { + var copyIcon = ''; + var checkIcon = ''; copyBtn.addEventListener("click", function () { var code = gen.querySelector("#cg-code"); if (code && code.textContent) { navigator.clipboard.writeText(code.textContent).then(function () { - copyBtn.textContent = "Copied!"; - setTimeout(function () { copyBtn.textContent = "Copy"; }, 2000); + copyBtn.innerHTML = checkIcon; + copyBtn.style.color = "var(--md-primary-fg-color)"; + setTimeout(function () { + copyBtn.innerHTML = copyIcon; + copyBtn.style.color = ""; + }, 2000); }); } }); } - // Upstream checkbox special handling - var upstreamCheck = gen.querySelector("#cg-upstream-check"); - if (upstreamCheck) { - upstreamCheck.addEventListener("change", function () { - var input = gen.querySelector('[data-key="upstream-base-url"]'); - if (input) input.value = upstreamCheck.checked ? "https://ntfy.sh" : ""; - updateOutput(); - }); + // Pre-fill base-url if not on ntfy.sh + var baseUrlInput = gen.querySelector('[data-key="base-url"]'); + if (baseUrlInput && !baseUrlInput.value.trim()) { + var host = window.location.hostname; + if (host && host.indexOf("ntfy.sh") === -1) { + baseUrlInput.value = "https://ntfy.example.com"; + } } - // Metrics checkbox special handling - var metricsCheck = gen.querySelector("#cg-metrics-check"); - if (metricsCheck) { - metricsCheck.addEventListener("change", function () { - var input = gen.querySelector('[data-key="enable-metrics"]'); - if (input) input.checked = metricsCheck.checked; - updateOutput(); - }); - } - - updateConditionalVisibility(); + updateVisibility(); updateOutput(); } From 19d1618bb8ae2712d4223a547e5fe7d7213f1fb5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Mar 2026 22:00:08 -0400 Subject: [PATCH 279/548] Continued --- docs/config.md | 132 ++++--- docs/static/css/config-generator.css | 559 +++++++++++++++------------ docs/static/js/config-generator.js | 541 +++++++++++++++++++------- 3 files changed, 796 insertions(+), 436 deletions(-) diff --git a/docs/config.md b/docs/config.md index 2fba771d..50d45c71 100644 --- a/docs/config.md +++ b/docs/config.md @@ -137,65 +137,83 @@ using Docker Compose (i.e. `docker-compose.yml`): ## Config generator -Use this interactive tool to build your ntfy configuration. Select options below and copy the generated config. - -
    + +
    ## Attachments -_Supported on:_ :material-android: :material-firefox: +_Supported on:_ :material-android: :material-apple: :material-firefox: You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded onto your phone (depending on size and setting automatically), and can be used from the Downloads folder. diff --git a/docs/releases.md b/docs/releases.md index a81fc47c..ce6c3bf0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1905,6 +1905,24 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +## ntfy iOS app v1.7.0 (UNRELEASED) + +This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS +feature gaps. Images sent via the `Attach` header (or as a PUT body) are now previewed inline in the notification banner +and inside the app, and other attachments can be downloaded, previewed via Quick Look, and shared from the notification +row. There's also a new "Download attachments" setting to control auto-download by size. + +**Features:** + +* Show image previews in notifications and inline in the notification list, with tap-to-zoom Quick Look preview and share sheet ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), [#276](https://github.com/binwiederhier/ntfy/issues/276), [#1226](https://github.com/binwiederhier/ntfy/issues/1226), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Download non-image attachments on demand with progress indication, persist them locally, and reuse files already fetched by the notification service extension ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Add "Download attachments" setting with size thresholds (Never, Under 100 KB / 500 KB / 1 MB / 5 MB / 10 MB / 50 MB, Always) to control automatic attachment downloads ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) + +**Bug fixes + maintenance:** + +* Improve background download reliability so attachments continue downloading when the app is suspended ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Reorganize notification and subscription views into their own folders and split out `NotificationRowView` for readability ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) + ## ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out From 134c4dd079226d2c518a3c87f0e4f86c3037e57f Mon Sep 17 00:00:00 2001 From: Vincent Vu <172068404+rubixvi@users.noreply.github.com> Date: Tue, 26 May 2026 17:30:58 +1000 Subject: [PATCH 452/548] Add new blog and forum posts for ntfy integration --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index a78d46f8..a9ae80bf 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -191,6 +191,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had ## Blog + forum posts +- [Push alerts for WHM using ntfy](https://rubixstudios.com.au/insights/push-alerts-for-whm-using-ntfy) - rubixstudios.com.au - 6/2026 - [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025 - [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025 - [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025 From 6796f6147b9a72fb9a584c4cbb6daeadda19a245 Mon Sep 17 00:00:00 2001 From: Vincent Vu <172068404+rubixvi@users.noreply.github.com> Date: Tue, 26 May 2026 17:44:29 +1000 Subject: [PATCH 453/548] Fix date for WHM push alerts blog post Updated the date for the blog post on WHM push alerts. --- docs/integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations.md b/docs/integrations.md index a9ae80bf..d32933d9 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -191,7 +191,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had ## Blog + forum posts -- [Push alerts for WHM using ntfy](https://rubixstudios.com.au/insights/push-alerts-for-whm-using-ntfy) - rubixstudios.com.au - 6/2026 +- [Push alerts for WHM using ntfy](https://rubixstudios.com.au/insights/push-alerts-for-whm-using-ntfy) - rubixstudios.com.au - 5/2026 - [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025 - [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025 - [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025 From dbcc89ed8b431bc43042dbb3e07e000ab7eff9a5 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Wed, 27 May 2026 16:42:26 +0200 Subject: [PATCH 454/548] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/ --- web/public/static/langs/zh_Hant.json | 414 +++++++++++++-------------- 1 file changed, 207 insertions(+), 207 deletions(-) diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index a340e2c7..07a92411 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -1,34 +1,34 @@ { - "account_basics_password_description": "更改你的帳戶密碼", - "account_basics_password_dialog_button_submit": "更改密碼", + "account_basics_password_description": "變更帳戶密碼", + "account_basics_password_dialog_button_submit": "變更密碼", "account_basics_password_dialog_confirm_password_label": "確認密碼", "account_basics_password_dialog_current_password_incorrect": "密碼錯誤", - "account_basics_password_dialog_current_password_label": "當前密碼", + "account_basics_password_dialog_current_password_label": "目前密碼", "account_basics_password_dialog_new_password_label": "新密碼", - "account_basics_password_dialog_title": "更改密碼", + "account_basics_password_dialog_title": "變更密碼", "account_basics_password_title": "密碼", - "account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼板", + "account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼簿", "account_basics_phone_numbers_description": "電話通知", "account_basics_phone_numbers_dialog_channel_call": "撥打", - "account_basics_phone_numbers_dialog_channel_sms": "短信", + "account_basics_phone_numbers_dialog_channel_sms": "簡訊", "account_basics_phone_numbers_dialog_check_verification_button": "確認碼", "account_basics_phone_numbers_dialog_code_label": "驗證碼", "account_basics_phone_numbers_dialog_code_placeholder": "例如:123456", - "account_basics_phone_numbers_dialog_description": "要使用來電通知功能,你需要新增並驗證至少一個電話號碼。可以通過短信或電話驗證。", + "account_basics_phone_numbers_dialog_description": "若要使用來電通知功能,請先新增並驗證至少一個電話號碼。你可以透過簡訊或電話完成驗證。", "account_basics_phone_numbers_dialog_number_label": "電話號碼", "account_basics_phone_numbers_dialog_number_placeholder": "例如:+1222333444", "account_basics_phone_numbers_dialog_title": "新增電話號碼", "account_basics_phone_numbers_dialog_verify_button_call": "撥打電話", - "account_basics_phone_numbers_dialog_verify_button_sms": "發送資訊", - "account_basics_phone_numbers_no_phone_numbers_yet": "無可執行的電話號碼", + "account_basics_phone_numbers_dialog_verify_button_sms": "傳送簡訊", + "account_basics_phone_numbers_no_phone_numbers_yet": "尚無電話號碼", "account_basics_phone_numbers_title": "電話號碼", "account_basics_tier_admin_suffix_no_tier": "(無等級)", "account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等級)", "account_basics_tier_admin": "管理員", "account_basics_tier_basic": "基礎版", "account_basics_tier_canceled_subscription": "你的訂閱已取消,並將在 {{date}} 降級為免費帳戶。", - "account_basics_tier_change_button": "改變", - "account_basics_tier_description": "你帳戶的權限級別", + "account_basics_tier_change_button": "變更", + "account_basics_tier_description": "你的帳戶權限等級", "account_basics_tier_free": "免費", "account_basics_tier_interval_monthly": "每月", "account_basics_tier_interval_yearly": "每年", @@ -40,149 +40,149 @@ "account_basics_title": "帳戶", "account_basics_username_admin_tooltip": "你是管理員", "account_basics_username_description": "嘿,那是你 ❤", - "account_basics_username_title": "用戶名", + "account_basics_username_title": "使用者名稱", "account_delete_description": "永久刪除你的帳戶", - "account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再訪問計費儀錶板。", + "account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再存取計費儀錶板。", "account_delete_dialog_button_cancel": "取消", "account_delete_dialog_button_submit": "永久刪除帳戶", - "account_delete_dialog_description": "這將永久刪除你的帳戶,包括存儲在伺服器上的所有數據。刪除後,你的用戶名將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。", + "account_delete_dialog_description": "這將永久刪除你的帳戶,包括儲存在伺服器上的所有資料。刪除後,你的使用者名稱將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。", "account_delete_dialog_label": "密碼", "account_delete_title": "刪除帳戶", - "account_tokens_delete_dialog_description": "在刪除訪問令牌之前,請確保沒有應用程序或腳本正在活躍使用它。 此操作無法撤銷。", - "account_tokens_delete_dialog_submit_button": "永久删除令牌", - "account_tokens_delete_dialog_title": "刪除訪問令牌", - "account_tokens_description": "通過 ntfy API 發布和訂閱時使用訪問令牌,因此你不必發送你的帳戶憑證。查看文檔以了解更多資訊。", + "account_tokens_delete_dialog_description": "在刪除存取權杖之前,請確認沒有應用程式或指令碼正在使用此權杖。此操作無法復原。", + "account_tokens_delete_dialog_submit_button": "永久刪除權杖", + "account_tokens_delete_dialog_title": "刪除存取權杖", + "account_tokens_description": "透過 ntfy API 發布和訂閱時,請使用存取權杖,這樣就不必傳送你的帳戶憑證。請參閱說明以瞭解更多資訊。", "account_tokens_dialog_button_cancel": "取消", - "account_tokens_dialog_button_create": "創建令牌", - "account_tokens_dialog_button_update": "更新令牌", - "account_tokens_dialog_expires_label": "訪問令牌過期於", - "account_tokens_dialog_expires_never": "令牌永不過期", + "account_tokens_dialog_button_create": "建立權杖", + "account_tokens_dialog_button_update": "更新權杖", + "account_tokens_dialog_expires_label": "存取權杖到期時間", + "account_tokens_dialog_expires_never": "權杖永不過期", "account_tokens_dialog_expires_unchanged": "保持過期日期不變", - "account_tokens_dialog_expires_x_days": "令牌在 {{days}} 天後過期", - "account_tokens_dialog_expires_x_hours": "令牌在 {{hours}} 小時後過期", + "account_tokens_dialog_expires_x_days": "權杖將在 {{days}} 天後到期", + "account_tokens_dialog_expires_x_hours": "權杖將在 {{hours}} 小時後到期", "account_tokens_dialog_label": "標籤,例如:Radarr 通知", - "account_tokens_dialog_title_create": "創建訪問令牌", - "account_tokens_dialog_title_delete": "刪除訪問令牌", - "account_tokens_dialog_title_edit": "編輯訪問令牌", - "account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除當前會話令牌", - "account_tokens_table_copied_to_clipboard": "已複製訪問令牌", - "account_tokens_table_create_token_button": "創建訪問令牌", - "account_tokens_table_current_session": "當前瀏覽器會話", + "account_tokens_dialog_title_create": "建立存取權杖", + "account_tokens_dialog_title_delete": "刪除存取權杖", + "account_tokens_dialog_title_edit": "編輯存取權杖", + "account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除目前的工作階段權杖", + "account_tokens_table_copied_to_clipboard": "已複製存取權杖", + "account_tokens_table_create_token_button": "建立存取權杖", + "account_tokens_table_current_session": "目前的瀏覽器工作階段", "account_tokens_table_expires_header": "過期", "account_tokens_table_label_header": "標籤", - "account_tokens_table_last_access_header": "最後訪問", - "account_tokens_table_last_origin_tooltip": "於IP地址 {{ip}},點擊查找", + "account_tokens_table_last_access_header": "最後存取", + "account_tokens_table_last_origin_tooltip": "來自 IP 位址 {{ip}},點選即可查詢", "account_tokens_table_never_expires": "永不過期", - "account_tokens_table_token_header": "令牌", - "account_tokens_title": "訪問令牌", - "account_upgrade_dialog_billing_contact_email": "有關賬單問題,請直接聯繫我們 。", - "account_upgrade_dialog_billing_contact_website": "有關賬單問題,請參考我們的網站 。", + "account_tokens_table_token_header": "權杖", + "account_tokens_title": "存取權杖", + "account_upgrade_dialog_billing_contact_email": "如有帳單問題,請直接聯絡我們。", + "account_upgrade_dialog_billing_contact_website": "如有帳單問題,請參考我們的網站。", "account_upgrade_dialog_button_cancel_subscription": "取消訂閱", "account_upgrade_dialog_button_cancel": "取消", "account_upgrade_dialog_button_pay_now": "立即付款並訂閱", "account_upgrade_dialog_button_redirect_signup": "立即註冊", "account_upgrade_dialog_button_update_subscription": "更新訂閱", - "account_upgrade_dialog_cancel_warning": "這將取消你的訂閱,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及緩存在伺服器上的訊息將被刪除。", + "account_upgrade_dialog_cancel_warning": "這將取消你的訂閱,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及快取在伺服器上的訊息將被刪除。", "account_upgrade_dialog_interval_monthly": "每月", "account_upgrade_dialog_interval_yearly_discount_save_up_to": "節省高達 {{discount}}%", "account_upgrade_dialog_interval_yearly_discount_save": "節省 {{discount}}%", "account_upgrade_dialog_interval_yearly": "每年", - "account_upgrade_dialog_proration_info": "按比例分配:在付費計劃之間升級時,差價將被立刻收取。在降級到較低級別時,餘額將被用於支付未來的賬單周期。", - "account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,請至少刪除 1 項保留。你可以在設置中刪除保留。", - "account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,請至少刪除 {{count}} 項保留。你可以在設置中刪除保留。", - "account_upgrade_dialog_tier_current_label": "當前", - "account_upgrade_dialog_tier_features_attachment_file_size": "每個文件 {{filesize}}", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總存儲空間", - "account_upgrade_dialog_tier_features_calls_one": "每日一通電話", - "account_upgrade_dialog_tier_features_calls_other": "每日{{calls}} 通電話", - "account_upgrade_dialog_tier_features_emails_one": "每日一封郵件", - "account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 條郵件", - "account_upgrade_dialog_tier_features_messages_one": "每日一條訊息", - "account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 條訊息", + "account_upgrade_dialog_proration_info": "按比例計費:升級到其他付費方案時,系統會立即收取差價。降級到較低等級時,餘額會用於支付未來的帳單週期。", + "account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於目前等級。在變更你的等級之前,請至少刪除 1 項保留。你可以在設定中移除保留。", + "account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於目前等級。在變更你的等級之前,請至少刪除 {{count}} 項保留。你可以在設定中移除保留。", + "account_upgrade_dialog_tier_current_label": "目前", + "account_upgrade_dialog_tier_features_attachment_file_size": "每個檔案 {{filesize}}", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總儲存空間", + "account_upgrade_dialog_tier_features_calls_one": "每日 {{calls}} 通電話", + "account_upgrade_dialog_tier_features_calls_other": "每日 {{calls}} 通電話", + "account_upgrade_dialog_tier_features_emails_one": "每日 {{emails}} 封電子郵件", + "account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 封電子郵件", + "account_upgrade_dialog_tier_features_messages_one": "每日 {{messages}} 則訊息", + "account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 則訊息", "account_upgrade_dialog_tier_features_no_calls": "沒有電話", "account_upgrade_dialog_tier_features_no_reservations": "無保留主題", - "account_upgrade_dialog_tier_features_reservations_one": "保留一條主題", - "account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 條主題", + "account_upgrade_dialog_tier_features_reservations_one": "保留 {{reservations}} 個主題", + "account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 個主題", "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月計費。", - "account_upgrade_dialog_tier_price_billed_yearly": "{{價格}} 按年計費。節省 {{save}}。", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} 按年計費。節省 {{save}}。", "account_upgrade_dialog_tier_price_per_month": "月", "account_upgrade_dialog_tier_selected_label": "已選", - "account_upgrade_dialog_title": "更改帳戶等級", - "account_usage_attachment_storage_description": "每個文件 {{filesize}},在 {{expiry}} 後刪除", - "account_usage_attachment_storage_title": "附件存儲", - "account_usage_basis_ip_description": "此帳戶的使用統計資訊和限制基於你的 IP 地址,因此可能會與其他用戶共享。上面顯示的限制是基於現有速率限制的近似值。", + "account_upgrade_dialog_title": "變更帳戶等級", + "account_usage_attachment_storage_description": "每個檔案 {{filesize}},在 {{expiry}} 後刪除", + "account_usage_attachment_storage_title": "附件儲存", + "account_usage_basis_ip_description": "此帳戶會依你的 IP 位址計算使用統計與限制,因此可能會與其他使用者共用。上方限制是根據現有速率限制估算的近似值。", "account_usage_calls_none": "此帳號無法撥打電話", "account_usage_calls_title": "已撥打電話", - "account_usage_cannot_create_portal_session": "無法打開計費門戶", - "account_usage_emails_title": "已發送電子郵件", - "account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置", + "account_usage_cannot_create_portal_session": "無法開啟計費入口網站", + "account_usage_emails_title": "已傳送電子郵件", + "account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重設", "account_usage_messages_title": "已發布訊息", - "account_usage_of_limit": "{{limit}} 的", + "account_usage_of_limit": "/ {{limit}}", "account_usage_reservations_none": "此帳戶沒有保留主題", "account_usage_reservations_title": "保留主題", "account_usage_title": "使用量", "account_usage_unlimited": "無限", "action_bar_account": "帳戶", - "action_bar_change_display_name": "更改顯示名稱", + "action_bar_change_display_name": "變更顯示名稱", "action_bar_clear_notifications": "清除所有通知", - "action_bar_logo_alt": "ntfy 標識", + "action_bar_logo_alt": "ntfy 標誌", "action_bar_mute_notifications": "靜音", "action_bar_profile_logout": "登出", "action_bar_profile_settings": "設定", "action_bar_profile_title": "個人資料", "action_bar_reservation_add": "保留主題", "action_bar_reservation_delete": "移除保留", - "action_bar_reservation_edit": "更改保留", + "action_bar_reservation_edit": "變更保留", "action_bar_reservation_limit_reached": "達到限制", - "action_bar_send_test_notification": "發送測試通知", + "action_bar_send_test_notification": "傳送測試通知", "action_bar_settings": "設定", "action_bar_show_menu": "顯示選單", - "action_bar_sign_in": "登錄", + "action_bar_sign_in": "登入", "action_bar_sign_up": "註冊", "action_bar_toggle_action_menu": "開啟或關閉操作選單", "action_bar_toggle_mute": "通知靜音/解除通知靜音", "action_bar_unmute_notifications": "取消靜音", "action_bar_unsubscribe": "取消訂閱", - "alert_notification_ios_install_required_description": "要接收通知,請在 iOS 上點擊共享,然後添加到主屏幕", + "alert_notification_ios_install_required_description": "若要接收通知,請在 iOS 上點選 [分享],然後選擇 [加入主畫面]", "alert_notification_ios_install_required_title": "需要安裝 iOS 應用程式", - "alert_notification_permission_denied_description": "你已禁用通知。要重新啟用通知,請在瀏覽器設置中啟用通知", - "alert_notification_permission_denied_title": "已禁用通知", + "alert_notification_permission_denied_description": "你已停用通知。若要重新啟用通知,請在瀏覽器設定中開啟通知", + "alert_notification_permission_denied_title": "已停用通知", "alert_notification_permission_required_button": "現在授予", - "alert_notification_permission_required_description": "授予瀏覽器顯示桌面通知的權限", - "alert_notification_permission_required_title": "已禁用通知", + "alert_notification_permission_required_description": "請授予瀏覽器顯示桌面通知的權限", + "alert_notification_permission_required_title": "已停用通知", "alert_not_supported_context_description": "通知僅支援 HTTPS。這是 Notifications API 的限制。", "alert_not_supported_description": "你的瀏覽器不支援通知", "alert_not_supported_title": "不支援通知", "common_add": "新增", - "common_back": "返回", + "common_back": "上一頁", "common_cancel": "取消", - "common_copy_to_clipboard": "複製到剪貼板", - "common_save": "保存", - "display_name_dialog_description": "為訂閱列表中顯示的主題設置一個替代名稱。這有助於更輕鬆地識別名稱複雜的主題。", + "common_copy_to_clipboard": "複製到剪貼簿", + "common_save": "儲存", + "display_name_dialog_description": "為訂閱清單中顯示的主題設定替代名稱。這有助於更輕鬆地識別名稱複雜的主題。", "display_name_dialog_placeholder": "顯示名稱", - "display_name_dialog_title": "更改顯示名稱", - "emoji_picker_search_clear": "清除搜索", - "emoji_picker_search_placeholder": "查找表情符號", - "error_boundary_button_copy_stack_trace": "複製堆疊追踪", - "error_boundary_button_reload_ntfy": "重新加載 ntfy", - "error_boundary_description": "這顯然不應該發生。對此非常抱歉。
    如果你有時間,請在GitHub上報告,或通過Discord或Matrix告訴我們。", + "display_name_dialog_title": "變更顯示名稱", + "emoji_picker_search_clear": "清除搜尋", + "emoji_picker_search_placeholder": "搜尋表情符號", + "error_boundary_button_copy_stack_trace": "複製堆疊追蹤", + "error_boundary_button_reload_ntfy": "重新載入 ntfy", + "error_boundary_description": "這顯然不應該發生。對此非常抱歉。
    如果你有時間,請在GitHub回報,或透過Discord或Matrix聯絡我們。", "error_boundary_gathering_info": "收集更多資訊……", - "error_boundary_stack_trace": "堆疊追踪", + "error_boundary_stack_trace": "堆疊追蹤", "error_boundary_title": "天啊,ntfy 崩潰了", - "error_boundary_unsupported_indexeddb_description": "Ntfy Web應用程式需要IndexedDB才能運行,且你的瀏覽器在隱私瀏覽模式下不支援IndexedDB。

    儘管這很不幸,但在隱私瀏覽模式下使用ntfy Web應用程式也沒有多大意義,因為所有東西都存儲在瀏覽器存儲中。你可以在本GitHub問題中閱讀有關它的更多資訊,或者在Discord或Matrix上與我們交談。", + "error_boundary_unsupported_indexeddb_description": "ntfy Web 應用程式需要 IndexedDB 才能運作,而你的瀏覽器在隱私瀏覽模式下不支援 IndexedDB。

    很遺憾,不過在隱私瀏覽模式下使用 ntfy Web 應用程式其實也不太合理,因為所有資料都儲存在瀏覽器儲存空間中。你可以在這個 GitHub issue中閱讀更多資訊,或透過Discord或Matrix與我們討論。", "error_boundary_unsupported_indexeddb_title": "不支援隱私瀏覽", - "login_disabled": "登錄已禁用", - "login_form_button_submit": "登錄", + "login_disabled": "登入已停用", + "login_form_button_submit": "登入", "login_link_signup": "註冊", - "login_title": "請登錄你的 ntfy 帳戶", - "message_bar_error_publishing": "發佈通知時出錯", + "login_title": "請登入你的 ntfy 帳戶", + "message_bar_error_publishing": "發布通知時發生錯誤", "message_bar_publish": "發布訊息", - "message_bar_show_dialog": "顯示發布對話框", + "message_bar_show_dialog": "顯示發布對話方塊", "message_bar_type_message": "在此處輸入訊息", "nav_button_account": "帳戶", "nav_button_all_notifications": "全部通知", - "nav_button_connecting": "正在連接", - "nav_button_documentation": "文檔", + "nav_button_connecting": "正在連線", + "nav_button_documentation": "說明", "nav_button_muted": "已暫停通知", "nav_button_publish_message": "發布通知", "nav_button_settings": "設定", @@ -191,44 +191,44 @@ "nav_upgrade_banner_description": "保留主題,更多訊息和郵件,以及更大的附件", "nav_upgrade_banner_label": "升級到 ntfy Pro", "notifications_actions_failed_notification": "通知失敗", - "notifications_actions_http_request_title": "發送 HTTP {{method}} 到 {{url}}", - "notifications_actions_not_supported": "網頁應用程序不支援此操作", + "notifications_actions_http_request_title": "傳送 HTTP {{method}} 到 {{url}}", + "notifications_actions_not_supported": "網頁應用程式不支援此操作", "notifications_actions_open_url_title": "轉到 {{url}}", - "notifications_attachment_copy_url_button": "複製連結地址", - "notifications_attachment_copy_url_title": "將附件中連結地址複製到剪貼板", - "notifications_attachment_file_app": "安卓應用程式", - "notifications_attachment_file_audio": "聲音文件", - "notifications_attachment_file_document": "其他文件", - "notifications_attachment_file_image": "圖片文件", - "notifications_attachment_file_video": "影片文件", + "notifications_attachment_copy_url_button": "複製 URL", + "notifications_attachment_copy_url_title": "將附件 URL 複製到剪貼簿", + "notifications_attachment_file_app": "Android 應用程式檔案", + "notifications_attachment_file_audio": "音訊檔案", + "notifications_attachment_file_document": "其他檔案", + "notifications_attachment_file_image": "圖片檔案", + "notifications_attachment_file_video": "影片檔案", "notifications_attachment_image": "附件圖片", "notifications_attachment_link_expired": "下載連結已過期", "notifications_attachment_link_expires": "連結在 {{date}} 過期", - "notifications_attachment_open_button": "打開附件", + "notifications_attachment_open_button": "開啟附件", "notifications_attachment_open_title": "轉到 {{url}}", - "notifications_click_copy_url_button": "複製鏈結", - "notifications_click_copy_url_title": "複製鏈結地址到剪貼板", - "notifications_click_open_button": "打開鏈結", - "notifications_copied_to_clipboard": "複製到剪貼板", + "notifications_click_copy_url_button": "複製連結", + "notifications_click_copy_url_title": "將連結 URL 複製到剪貼簿", + "notifications_click_open_button": "開啟連結", + "notifications_copied_to_clipboard": "複製到剪貼簿", "notifications_delete": "刪除", - "notifications_example": "示例", + "notifications_example": "範例", "notifications_list_item": "通知", - "notifications_list": "通知列表", - "notifications_loading": "正在加載通知……", + "notifications_list": "通知清單", + "notifications_loading": "正在載入通知……", "notifications_mark_read": "標記為已讀", - "notifications_more_details": "有關更多資訊,請查看網站或文檔。", + "notifications_more_details": "如需更多資訊,請參閱網站或說明。", "notifications_new_indicator": "新通知", - "notifications_none_for_any_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題鏈結即可。以下是使用你的主題的示例。", + "notifications_none_for_any_description": "若要向此主題傳送通知,只要將 PUT 或 POST 請求傳送到主題 URL 即可。以下是使用你的主題的範例。", "notifications_none_for_any_title": "你尚未收到任何通知。", - "notifications_none_for_topic_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題連結即可。", + "notifications_none_for_topic_description": "若要向此主題傳送通知,只要將 PUT 或 POST 請求傳送到主題 URL 即可。", "notifications_none_for_topic_title": "你尚未收到有關此主題的任何通知。", - "notifications_no_subscriptions_description": "點擊 \"{{linktext}}\" 連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 發送訊息,你將在這裡收到通知。", - "notifications_no_subscriptions_title": "看起來你還未有任何訂閱。", - "notifications_priority_x": "優先級 {{priority}}", + "notifications_no_subscriptions_description": "點選「{{linktext}}」連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 傳送訊息,並在這裡收到通知。", + "notifications_no_subscriptions_title": "看起來你還沒有任何訂閱。", + "notifications_priority_x": "優先順序 {{priority}}", "notifications_tags": "標記", "prefs_appearance_language_title": "語言", - "prefs_appearance_theme_dark": "黑暗模式", - "prefs_appearance_theme_light": "光亮模式", + "prefs_appearance_theme_dark": "深色模式", + "prefs_appearance_theme_light": "淺色模式", "prefs_appearance_theme_system": "系統 (預設)", "prefs_appearance_theme_title": "主題", "prefs_appearance_title": "外觀", @@ -238,171 +238,171 @@ "prefs_notifications_delete_after_one_day": "一天後", "prefs_notifications_delete_after_one_month_description": "一個月後自動刪除通知", "prefs_notifications_delete_after_one_month": "一個月後", - "prefs_notifications_delete_after_one_week_description": "一周後自動刪除通知", - "prefs_notifications_delete_after_one_week": "一周後", + "prefs_notifications_delete_after_one_week_description": "一週後自動刪除通知", + "prefs_notifications_delete_after_one_week": "一週後", "prefs_notifications_delete_after_three_hours_description": "三小時後自動刪除通知", "prefs_notifications_delete_after_three_hours": "三小時後", "prefs_notifications_delete_after_title": "刪除通知", - "prefs_notifications_min_priority_any": "任意優先級", - "prefs_notifications_min_priority_default_and_higher": "默認優先級或更高", - "prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先級如何", - "prefs_notifications_min_priority_description_max": "僅顯示最高優先級的通知", - "prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先級為{{number}}({{name}})或以上的通知", - "prefs_notifications_min_priority_high_and_higher": "高優先級或更高", - "prefs_notifications_min_priority_low_and_higher": "低優先級或更高", - "prefs_notifications_min_priority_max_only": "僅最高優先級", - "prefs_notifications_min_priority_title": "最低優先級", + "prefs_notifications_min_priority_any": "任何優先順序", + "prefs_notifications_min_priority_default_and_higher": "預設優先順序或更高", + "prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先順序如何", + "prefs_notifications_min_priority_description_max": "僅顯示最高優先順序的通知", + "prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先順序為 {{number}}({{name}})或以上的通知", + "prefs_notifications_min_priority_high_and_higher": "高優先順序或更高", + "prefs_notifications_min_priority_low_and_higher": "低優先順序或更高", + "prefs_notifications_min_priority_max_only": "僅最高優先順序", + "prefs_notifications_min_priority_title": "最低優先順序", "prefs_notifications_sound_description_none": "收到通知時不播放任何聲音", "prefs_notifications_sound_description_some": "收到通知時播放 {{sound}} 聲音", "prefs_notifications_sound_no_sound": "靜音", - "prefs_notifications_sound_play": "播放選中聲音", + "prefs_notifications_sound_play": "播放選取的聲音", "prefs_notifications_sound_title": "通知提示音", "prefs_notifications_title": "通知", - "prefs_notifications_web_push_disabled_description": "當網頁程式在運行時將會收到通知 (透過 WebSocket)", - "prefs_notifications_web_push_disabled": "己暫用", - "prefs_notifications_web_push_enabled_description": "即使網頁程式未有運街亦會收到通知 (via Web Push)", - "prefs_notifications_web_push_enabled": "己為 {{server}} 啟用", + "prefs_notifications_web_push_disabled_description": "網頁應用程式執行時會收到通知(透過 WebSocket)", + "prefs_notifications_web_push_disabled": "已停用", + "prefs_notifications_web_push_enabled_description": "即使網頁應用程式未執行,也會收到通知(透過 Web Push)", + "prefs_notifications_web_push_enabled": "已為 {{server}} 啟用", "prefs_notifications_web_push_title": "背景通知", "prefs_reservations_add_button": "新增保留主題", - "prefs_reservations_delete_button": "重置主題訪問", - "prefs_reservations_description": "你可以在此處保留主題名稱供個人使用。保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。", - "prefs_reservations_dialog_access_label": "訪問", - "prefs_reservations_dialog_description": "保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。", + "prefs_reservations_delete_button": "重設主題存取權限", + "prefs_reservations_description": "你可以在此保留主題名稱供個人使用。保留主題代表你擁有該主題的所有權,並可為其他使用者定義該主題的存取權限。", + "prefs_reservations_dialog_access_label": "存取權限", + "prefs_reservations_dialog_description": "保留主題代表你擁有該主題的所有權,並可為其他使用者定義該主題的存取權限。", "prefs_reservations_dialog_title_add": "保留主題", "prefs_reservations_dialog_title_delete": "刪除主題保留", "prefs_reservations_dialog_title_edit": "編輯保留主題", "prefs_reservations_dialog_topic_label": "主題", - "prefs_reservations_edit_button": "編輯主題訪問", + "prefs_reservations_edit_button": "編輯主題存取權限", "prefs_reservations_limit_reached": "你已達到保留主題限制。", - "prefs_reservations_table_access_header": "訪問", - "prefs_reservations_table_click_to_subscribe": "點擊以訂閱", - "prefs_reservations_table_everyone_deny_all": "只有我可以發佈和訂閱", - "prefs_reservations_table_everyone_read_only": "我可以發佈和訂閱,每個人都可以訂閱", - "prefs_reservations_table_everyone_read_write": "每個人都可以發佈和訂閱", - "prefs_reservations_table_everyone_write_only": "我可以發佈和訂閱,每個人都可以發佈", + "prefs_reservations_table_access_header": "存取權限", + "prefs_reservations_table_click_to_subscribe": "點選以訂閱", + "prefs_reservations_table_everyone_deny_all": "只有我可以發布和訂閱", + "prefs_reservations_table_everyone_read_only": "我可以發布和訂閱,每個人都可以訂閱", + "prefs_reservations_table_everyone_read_write": "每個人都可以發布和訂閱", + "prefs_reservations_table_everyone_write_only": "我可以發布和訂閱,每個人都可以發布", "prefs_reservations_table_not_subscribed": "未訂閱", "prefs_reservations_table_topic_header": "主題", "prefs_reservations_table": "保留主題表格", "prefs_reservations_title": "保留主題", "prefs_users_add_button": "新增使用者", - "prefs_users_delete_button": "刪除用戶", - "prefs_users_description_no_sync": "用戶和密碼不會同步到你的賬戶。", - "prefs_users_description": "在此處新增/刪除受保護主題的使用者。請注意,使用者名和密碼將存儲在瀏覽器的本地存儲中。", - "prefs_users_dialog_base_url_label": "服務連結地址,例如 https://ntfy.sh", + "prefs_users_delete_button": "刪除使用者", + "prefs_users_description_no_sync": "使用者和密碼不會同步到你的帳戶。", + "prefs_users_description": "在此新增或刪除受保護主題的使用者。請注意,使用者名稱和密碼會儲存在瀏覽器的本機儲存空間中。", + "prefs_users_dialog_base_url_label": "服務 URL,例如 https://ntfy.sh", "prefs_users_dialog_password_label": "密碼", "prefs_users_dialog_title_add": "新增使用者", "prefs_users_dialog_title_edit": "編輯使用者", - "prefs_users_dialog_username_label": "使用者名,例如 phil", - "prefs_users_edit_button": "編輯用戶", - "prefs_users_table_base_url_header": "服務連結地址", - "prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登錄用戶", - "prefs_users_table_user_header": "用戶", - "prefs_users_table": "用戶表", + "prefs_users_dialog_username_label": "使用者名稱,例如 phil", + "prefs_users_edit_button": "編輯使用者", + "prefs_users_table_base_url_header": "服務 URL", + "prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登入使用者", + "prefs_users_table_user_header": "使用者", + "prefs_users_table": "使用者表格", "prefs_users_title": "管理使用者", "priority_default": "預設", "priority_high": "高", "priority_low": "低", "priority_max": "最高", "priority_min": "最低", - "publish_dialog_attached_file_filename_placeholder": "附件文件名", - "publish_dialog_attached_file_remove": "刪除附件文件", - "publish_dialog_attached_file_title": "附件文件:", - "publish_dialog_attach_label": "附件連結地址", - "publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 文件限制和配額,剩餘 {{remainingBytes}}", - "publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 文件限制", + "publish_dialog_attached_file_filename_placeholder": "附件檔名", + "publish_dialog_attached_file_remove": "刪除附件檔案", + "publish_dialog_attached_file_title": "附件檔案:", + "publish_dialog_attach_label": "附件 URL", + "publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 的檔案限制和配額,剩餘 {{remainingBytes}}", + "publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 的檔案限制", "publish_dialog_attachment_limits_quota_reached": "超過配額,剩餘 {{remainingBytes}}", - "publish_dialog_attach_placeholder": "使用鏈結地址附加文件,例如 https://f-droid.org/F-Droid.apk", - "publish_dialog_attach_reset": "移除附件鏈結地址", - "publish_dialog_base_url_label": "服務鏈結地址", - "publish_dialog_base_url_placeholder": "服務鏈結地址,例如 https://example.com", - "publish_dialog_button_cancel_sending": "取消發送", + "publish_dialog_attach_placeholder": "透過 URL 附加檔案,例如 https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "移除附件 URL", + "publish_dialog_base_url_label": "服務 URL", + "publish_dialog_base_url_placeholder": "服務 URL,例如 https://example.com", + "publish_dialog_button_cancel_sending": "取消傳送", "publish_dialog_button_cancel": "取消", - "publish_dialog_button_send": "發送", + "publish_dialog_button_send": "傳送", "publish_dialog_call_item": "撥打電話 {{number}}", "publish_dialog_call_label": "撥號", "publish_dialog_call_reset": "清空撥號", "publish_dialog_checkbox_markdown": "格式化為 Markdown", "publish_dialog_checkbox_publish_another": "發布另一個", - "publish_dialog_chip_attach_file_label": "本地文件附件", - "publish_dialog_chip_attach_url_label": "鏈結附件地址", + "publish_dialog_chip_attach_file_label": "本機檔案附件", + "publish_dialog_chip_attach_url_label": "透過 URL 附加檔案", "publish_dialog_chip_call_label": "撥號", "publish_dialog_chip_call_no_verified_numbers_tooltip": "未驗證的電話號碼", - "publish_dialog_chip_click_label": "點擊鏈結地址", + "publish_dialog_chip_click_label": "點選 URL", "publish_dialog_chip_delay_label": "延期投遞", - "publish_dialog_chip_email_label": "轉發郵件", + "publish_dialog_chip_email_label": "轉寄電子郵件", "publish_dialog_chip_topic_label": "變更主題", - "publish_dialog_click_label": "點擊鏈結地址", - "publish_dialog_click_placeholder": "點擊通知時打開鏈結地址", - "publish_dialog_click_reset": "移除點擊連結地址", + "publish_dialog_click_label": "點選 URL", + "publish_dialog_click_placeholder": "點選通知時開啟的 URL", + "publish_dialog_click_reset": "移除點選 URL", "publish_dialog_delay_label": "延期", "publish_dialog_delay_placeholder": "延期投遞,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(僅限英語)", "publish_dialog_delay_reset": "刪除延期投遞", - "publish_dialog_details_examples_description": "有關所有發送功能的範例和詳細說明,請參閱文檔。", - "publish_dialog_drop_file_here": "將文件拖拽至此", + "publish_dialog_details_examples_description": "如需所有傳送功能的範例和詳細說明,請參閱說明。", + "publish_dialog_drop_file_here": "將檔案拖曳到這裡", "publish_dialog_email_label": "電子郵件", - "publish_dialog_email_placeholder": "將通知轉發到的地址,例如 phil@example.com", - "publish_dialog_email_reset": "移除電子郵件轉發", + "publish_dialog_email_placeholder": "要轉寄通知的電子郵件地址,例如 phil@example.com", + "publish_dialog_email_reset": "移除電子郵件轉寄", "publish_dialog_emoji_picker_show": "選擇表情符號", - "publish_dialog_filename_label": "文件名", - "publish_dialog_filename_placeholder": "附件文件名", + "publish_dialog_filename_label": "檔名", + "publish_dialog_filename_placeholder": "附件檔名", "publish_dialog_message_label": "訊息", "publish_dialog_message_placeholder": "在此輸入訊息", "publish_dialog_message_published": "已發布通知", - "publish_dialog_other_features": "其它功能:", - "publish_dialog_priority_default": "默認優先級", - "publish_dialog_priority_high": "高優先級", - "publish_dialog_priority_label": "優先級", - "publish_dialog_priority_low": "低優先級", - "publish_dialog_priority_max": "最高優先級", - "publish_dialog_priority_min": "最低優先級", + "publish_dialog_other_features": "其他功能:", + "publish_dialog_priority_default": "預設優先順序", + "publish_dialog_priority_high": "高優先順序", + "publish_dialog_priority_label": "優先順序", + "publish_dialog_priority_low": "低優先順序", + "publish_dialog_priority_max": "最高優先順序", + "publish_dialog_priority_min": "最低優先順序", "publish_dialog_progress_uploading_detail": "正在上傳 {{loaded}}/{{total}} ({{percent}}%) ……", "publish_dialog_progress_uploading": "正在上傳……", "publish_dialog_tags_label": "標記", - "publish_dialog_tags_placeholder": "英文逗號分隔標記列表,例如 warning, srv1-backup", + "publish_dialog_tags_placeholder": "以英文逗號分隔的標記清單,例如 warning, srv1-backup", "publish_dialog_title_label": "主題", "publish_dialog_title_no_topic": "發布通知", "publish_dialog_title_placeholder": "主題標題,例如:磁碟空間警告", "publish_dialog_title_topic": "發布到 {{topic}}", "publish_dialog_topic_label": "主題名稱", "publish_dialog_topic_placeholder": "主題名稱,例如 phil_alerts", - "publish_dialog_topic_reset": "重置主題", - "reservation_delete_dialog_action_delete_description": "緩存的郵件和附件將被永久刪除。此操作無法撤銷。", - "reservation_delete_dialog_action_delete_title": "刪除緩存的郵件和附件", - "reservation_delete_dialog_action_keep_description": "緩存在伺服器上的訊息和附件將對知道主題名稱的人公開可見。", - "reservation_delete_dialog_action_keep_title": "保留緩存的郵件和附件", - "reservation_delete_dialog_description": "刪除保留會放棄對該主題的所有權,並允許其他人保留它。你可以保留或刪除現有郵件和附件。", + "publish_dialog_topic_reset": "重設主題", + "reservation_delete_dialog_action_delete_description": "系統會永久刪除快取的訊息和附件。此操作無法復原。", + "reservation_delete_dialog_action_delete_title": "刪除快取的訊息和附件", + "reservation_delete_dialog_action_keep_description": "只要知道主題名稱,任何人都能看到伺服器快取的訊息和附件。", + "reservation_delete_dialog_action_keep_title": "保留快取的訊息和附件", + "reservation_delete_dialog_description": "刪除保留會放棄該主題的所有權,並允許其他人保留。你可以保留或刪除現有訊息和附件。", "reservation_delete_dialog_submit_button": "刪除保留", - "reserve_dialog_checkbox_label": "保留主題並配置訪問", - "signup_already_have_account": "已有帳戶?登錄!", - "signup_disabled": "註冊已禁用", - "signup_error_creation_limit_reached": "已達到帳戶創建限制", - "signup_error_username_taken": "用戶名 {{username}} 已被取用", + "reserve_dialog_checkbox_label": "保留主題並設定存取權限", + "signup_already_have_account": "已有帳戶?請登入!", + "signup_disabled": "註冊已停用", + "signup_error_creation_limit_reached": "已達到帳戶建立限制", + "signup_error_username_taken": "使用者名稱 {{username}} 已被取用", "signup_form_button_submit": "註冊", "signup_form_confirm_password": "確認密碼", "signup_form_password": "密碼", "signup_form_toggle_password_visibility": "切換密碼可見性", - "signup_form_username": "用戶名", - "signup_title": "創建一個 ntfy 帳戶", + "signup_form_username": "使用者名稱", + "signup_title": "建立 ntfy 帳戶", "subscribe_dialog_error_topic_already_reserved": "主題已保留", "subscribe_dialog_error_user_anonymous": "匿名", "subscribe_dialog_error_user_not_authorized": "未授權 {{username}} 使用者", "subscribe_dialog_login_button_login": "登入", - "subscribe_dialog_login_description": "本主題受密碼保護,請輸入用戶名和密碼以訂閱。", + "subscribe_dialog_login_description": "此主題受到密碼保護,請輸入使用者名稱和密碼以訂閱。", "subscribe_dialog_login_password_label": "密碼", - "subscribe_dialog_login_title": "請登錄", - "subscribe_dialog_login_username_label": "用戶名,例如 phil", - "subscribe_dialog_subscribe_base_url_label": "服務地址地址", + "subscribe_dialog_login_title": "請登入", + "subscribe_dialog_login_username_label": "使用者名稱,例如 phil", + "subscribe_dialog_subscribe_base_url_label": "服務 URL", "subscribe_dialog_subscribe_button_cancel": "取消", - "subscribe_dialog_subscribe_button_generate_topic_name": "生成名稱", + "subscribe_dialog_subscribe_button_generate_topic_name": "產生名稱", "subscribe_dialog_subscribe_button_subscribe": "訂閱", "subscribe_dialog_subscribe_description": "主題可能不受密碼保護,因此請選擇一個不容易被猜中的名字。訂閱後,你可以使用 PUT/POST 通知。", "subscribe_dialog_subscribe_title": "訂閱主題", - "subscribe_dialog_subscribe_topic_placeholder": "主題名,例如 phil_alerts", - "subscribe_dialog_subscribe_use_another_background_info": "當網頁程式未開啟, 將不會收到來自其他伺服器的通知", + "subscribe_dialog_subscribe_topic_placeholder": "主題名稱,例如 phil_alerts", + "subscribe_dialog_subscribe_use_another_background_info": "網頁應用程式未開啟時,不會收到來自其他伺服器的通知", "subscribe_dialog_subscribe_use_another_label": "使用其他伺服器", - "web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知", + "web_push_subscription_expiring_body": "開啟 ntfy 以繼續接收通知", "web_push_subscription_expiring_title": "通知會被暫停", - "web_push_unknown_notification_body": "你可能需要開啟網頁來更新ntfy", + "web_push_unknown_notification_body": "你可能需要開啟網頁來更新 ntfy", "web_push_unknown_notification_title": "接收到不明通知", "account_basics_cannot_edit_or_delete_provisioned_user": "已佈建的使用者無法編輯或刪除", "account_tokens_table_cannot_delete_or_edit_provisioned_token": "無法編輯或刪除已佈建的權杖" From 25520c450527dae6aa73b9ad1df99e527b278708 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 28 May 2026 17:13:14 -0400 Subject: [PATCH 455/548] WIP: Access cache --- cmd/access_test.go | 7 ++ cmd/user.go | 5 + cmd/user_test.go | 5 + server/config.go | 2 + server/server.go | 21 ++-- user/access_cache.go | 180 ++++++++++++++++++++++++++ user/access_cache_test.go | 259 ++++++++++++++++++++++++++++++++++++++ user/manager.go | 158 ++++++++++++++++------- user/manager_postgres.go | 8 +- user/manager_sqlite.go | 8 +- user/types.go | 12 +- 11 files changed, 601 insertions(+), 64 deletions(-) create mode 100644 user/access_cache.go create mode 100644 user/access_cache_test.go diff --git a/cmd/access_test.go b/cmd/access_test.go index 8810b6b3..f280a9e9 100644 --- a/cmd/access_test.go +++ b/cmd/access_test.go @@ -7,6 +7,7 @@ import ( "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/test" "testing" + "time" ) func TestCLI_Access_Show(t *testing.T) { @@ -43,6 +44,12 @@ user * (role: anonymous, tier: none) ` require.Equal(t, expected, stdout.String()) + // The CLI commands above ran against a separate Manager instance (their own + // process-equivalent), so the server's ACL cache hasn't seen the new grants + // yet. Wait for the server's background reloader (interval set in + // newTestServerWithAuth) to pick them up. + time.Sleep(150 * time.Millisecond) + // See if access permissions match app, _, _, _ = newTestApp() require.Error(t, app.Run([]string{ diff --git a/cmd/user.go b/cmd/user.go index cd6cf795..9bb0c5b0 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -378,6 +378,11 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, + // CLI Managers are short-lived; the background ACL cache poller would only + // spam "database is closed" warnings after the subcommand returns. Mutations + // still refresh the local cache synchronously; the running server (if any) + // picks them up via its own poller. + AccessCacheReloadInterval: -1, } if databaseURL != "" { host, dbErr := pg.Open(databaseURL) diff --git a/cmd/user_test.go b/cmd/user_test.go index ed6f5de4..5b5bff73 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestCLI_User_Add(t *testing.T) { @@ -128,6 +129,10 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, conf.File = configFile conf.AuthFile = filepath.Join(t.TempDir(), "user.db") conf.AuthDefault = user.PermissionDenyAll + // Tight interval so cross-process writes from the `ntfy access`/`ntfy user` + // CLI commands (which run via a separate Manager) propagate to the server's + // ACL cache within tens of ms instead of the default 5s. + conf.AuthAccessCacheReloadInterval = 25 * time.Millisecond s, port = test.StartServerWithConfig(t, conf) return } diff --git a/server/config.go b/server/config.go index 1cfed3fc..f6f13eca 100644 --- a/server/config.go +++ b/server/config.go @@ -116,6 +116,7 @@ type Config struct { AuthTokens map[string][]*user.Token AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration + AuthAccessCacheReloadInterval time.Duration AttachmentCacheDir string AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 @@ -223,6 +224,7 @@ func NewConfig() *Config { AuthDefault: user.PermissionReadWrite, AuthBcryptCost: user.DefaultUserPasswordBcryptCost, AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, + AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval, AttachmentCacheDir: "", AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, diff --git a/server/server.go b/server/server.go index 7ca0b4e7..7bcbbb09 100644 --- a/server/server.go +++ b/server/server.go @@ -247,16 +247,17 @@ func New(conf *Config) (*Server, error) { var userManager *user.Manager if conf.AuthFile != "" || pool != nil { authConfig := &user.Config{ - Filename: conf.AuthFile, - DatabaseURL: conf.DatabaseURL, - StartupQueries: conf.AuthStartupQueries, - DefaultAccess: conf.AuthDefault, - ProvisionEnabled: true, // Enable provisioning of users and access - Users: conf.AuthUsers, - Access: conf.AuthAccess, - Tokens: conf.AuthTokens, - BcryptCost: conf.AuthBcryptCost, - QueueWriterInterval: conf.AuthStatsQueueWriterInterval, + Filename: conf.AuthFile, + DatabaseURL: conf.DatabaseURL, + StartupQueries: conf.AuthStartupQueries, + DefaultAccess: conf.AuthDefault, + ProvisionEnabled: true, // Enable provisioning of users and access + Users: conf.AuthUsers, + Access: conf.AuthAccess, + Tokens: conf.AuthTokens, + BcryptCost: conf.AuthBcryptCost, + QueueWriterInterval: conf.AuthStatsQueueWriterInterval, + AccessCacheReloadInterval: conf.AuthAccessCacheReloadInterval, } if pool != nil { userManager, err = user.NewPostgresManager(pool, authConfig) diff --git a/user/access_cache.go b/user/access_cache.go new file mode 100644 index 00000000..98942d05 --- /dev/null +++ b/user/access_cache.go @@ -0,0 +1,180 @@ +package user + +import ( + "regexp" + "strings" + "sync/atomic" + + "heckel.io/ntfy/v2/db" +) + +// aclEntry mirrors one user_access row in the in-memory snapshot. +// +// topic is the raw stored value: it may contain \_ escapes (for literal underscores) +// and % wildcards (translated from user-supplied *). For exact-match entries (no %) +// matcher is nil and the entry is keyed by topic in aclSnapshot.exact. For wildcard +// entries (with %) matcher is the pre-compiled regex equivalent of the LIKE pattern. +type aclEntry struct { + topic string + read bool + write bool + matcher *regexp.Regexp +} + +// aclSnapshot is an immutable indexed form of the entire user_access table. +// +// exact[userName][escapedTopic] returns the matching entry in O(1) for the common +// case where the requested topic appears verbatim in some rule. The key is the +// stored form of the topic (i.e. with \_ escapes), so callers must pass topics +// through escapeUnderscore before probing. +// +// wildcards[userName] is the linear scan list of %-bearing rules for that user. +// Walked per request; trivially small in practice. Wildcards are NOT u_everyone- +// only -- any user can create them. +type aclSnapshot struct { + exact map[string]map[string]aclEntry + wildcards map[string][]aclEntry +} + +// aclCache holds the current snapshot behind an atomic pointer so that the hot +// path (Lookup) is lock-free. reload builds a fresh snapshot off the request +// path and atomically swaps the pointer; the old snapshot is GC'd once in-flight +// Lookups release their references. +// +// A nil receiver behaves as if no snapshot were loaded -- Lookup returns +// found=false, which the caller then resolves via DefaultAccess. This keeps +// tests and edge cases (e.g. early-startup) safe. +type aclCache struct { + snap atomic.Pointer[aclSnapshot] +} + +func newAccessCache() *aclCache { + return &aclCache{} +} + +// reload runs the bulk-load query against the primary and atomically swaps in +// a fresh snapshot. The primary is used (not ReadOnly) so a reload immediately +// after an ACL mutation sees the freshly-written rows without replica lag. +func (c *aclCache) reload(d *db.DB, query string) error { + rows, err := d.Query(query) + if err != nil { + return err + } + defer rows.Close() + snap := &aclSnapshot{ + exact: make(map[string]map[string]aclEntry), + wildcards: make(map[string][]aclEntry), + } + for rows.Next() { + var userName string + var entry aclEntry + if err := rows.Scan(&userName, &entry.topic, &entry.read, &entry.write); err != nil { + return err + } + if strings.Contains(entry.topic, "%") { + entry.matcher = compileLikeToRegex(entry.topic) + snap.wildcards[userName] = append(snap.wildcards[userName], entry) + } else { + if snap.exact[userName] == nil { + snap.exact[userName] = make(map[string]aclEntry) + } + snap.exact[userName][entry.topic] = entry + } + } + if err := rows.Err(); err != nil { + return err + } + c.snap.Store(snap) + return nil +} + +// Lookup returns the effective (read, write, found) permission for the given +// (username, topic), preserving the priority ordering of the original SQL query: +// 1. specific user beats Everyone +// 2. longer pattern beats shorter (more specific wins) +// 3. write beats read at equal length (write is "stronger") +func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found bool) { + if c == nil { + return false, false, false + } + snap := c.snap.Load() + if snap == nil { + return false, false, false + } + // Pre-compute the escaped form once: exact-match keys in the snapshot are + // stored as toSQLWildcard would emit them (literal _ -> \_), so the + // incoming topic must be escaped the same way before map lookup. + escaped := escapeUnderscore(topic) + + // Specific user takes priority over Everyone. Skip the first lookup when + // the request is already anonymous to avoid scanning the same map twice. + if usernameOrEveryone != Everyone { + if e, ok := pickBest(snap, usernameOrEveryone, topic, escaped); ok { + return e.read, e.write, true + } + } + if e, ok := pickBest(snap, Everyone, topic, escaped); ok { + return e.read, e.write, true + } + return false, false, false +} + +// pickBest returns the highest-priority entry for a single user, combining the +// exact-match O(1) probe with a linear scan over the (usually empty or tiny) +// wildcard list. Priority within a user: longer pattern wins; write wins ties. +func pickBest(snap *aclSnapshot, userName, topic, escaped string) (aclEntry, bool) { + var best aclEntry + var found bool + if m, ok := snap.exact[userName]; ok { + if e, ok := m[escaped]; ok { + best, found = e, true + } + } + for _, w := range snap.wildcards[userName] { + if !w.matcher.MatchString(topic) { + continue + } + if !found || better(w, best) { + best, found = w, true + } + } + return best, found +} + +// better implements the (length DESC, write DESC) tie-break used by the original +// query's ORDER BY for entries owned by the same user. +func better(a, b aclEntry) bool { + if len(a.topic) != len(b.topic) { + return len(a.topic) > len(b.topic) + } + if a.write != b.write { + return a.write + } + return false +} + +// compileLikeToRegex converts a stored ntfy LIKE pattern into an equivalent Go +// regexp. In ntfy's stored form, % is the only wildcard (translated from *) and +// \_ is a literal underscore; no other backslashes occur. Topics themselves are +// restricted to [A-Za-z0-9_-] (see AllowedTopic), so neither % nor stray +// backslashes appear in user-supplied input. +func compileLikeToRegex(pattern string) *regexp.Regexp { + var sb strings.Builder + sb.WriteString("^") + i := 0 + for i < len(pattern) { + switch { + case pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '_': + sb.WriteString(regexp.QuoteMeta("_")) + i += 2 + case pattern[i] == '%': + sb.WriteString(".*") + i++ + default: + sb.WriteString(regexp.QuoteMeta(string(pattern[i]))) + i++ + } + } + sb.WriteString("$") + return regexp.MustCompile(sb.String()) +} diff --git a/user/access_cache_test.go b/user/access_cache_test.go new file mode 100644 index 00000000..d8f11d71 --- /dev/null +++ b/user/access_cache_test.go @@ -0,0 +1,259 @@ +package user + +import ( + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +// Cache-only unit tests. Integration with the Manager (loading from the DB, +// reload-after-mutation, end-to-end Authorize behavior) is covered by the +// existing TestStoreAuthorizeTopicAccess* tests in manager_test.go via +// forEachStoreBackend. + +func TestCompileLikeToRegex_Exact(t *testing.T) { + r := compileLikeToRegex("foo") + require.True(t, r.MatchString("foo")) + require.False(t, r.MatchString("foox")) + require.False(t, r.MatchString("xfoo")) +} + +func TestCompileLikeToRegex_TrailingPercent(t *testing.T) { + r := compileLikeToRegex("up%") + require.True(t, r.MatchString("up")) + require.True(t, r.MatchString("up123")) + require.False(t, r.MatchString("xup")) +} + +func TestCompileLikeToRegex_LeadingAndEmbeddedPercent(t *testing.T) { + r := compileLikeToRegex("%test%") + require.True(t, r.MatchString("test")) + require.True(t, r.MatchString("mytest")) + require.True(t, r.MatchString("testxxx")) + require.True(t, r.MatchString("xtestx")) + require.False(t, r.MatchString("nope")) +} + +func TestCompileLikeToRegex_EscapedUnderscore(t *testing.T) { + // "my\_topic" is the stored form of a literal "my_topic" -- the underscore + // must match itself, NOT act as a SQL one-character wildcard. + r := compileLikeToRegex(`my\_topic`) + require.True(t, r.MatchString("my_topic")) + require.False(t, r.MatchString("myXtopic")) + require.False(t, r.MatchString("mytopic")) +} + +func TestCompileLikeToRegex_EscapedUnderscoreAdjacentToPercent(t *testing.T) { + // "nz\_vip\_%" is the stored form of "nz_vip_*" -- literal "nz_vip_" prefix + // followed by any suffix. + r := compileLikeToRegex(`nz\_vip\_%`) + require.True(t, r.MatchString("nz_vip_")) + require.True(t, r.MatchString("nz_vip_alpha")) + require.False(t, r.MatchString("nz_vipX")) + require.False(t, r.MatchString("nzvip_alpha")) +} + +func TestCompileLikeToRegex_RegexMetaCharsInTopic(t *testing.T) { + // Topics in ntfy can include '-', which is benign, but make sure + // regex metacharacters in the pattern are escaped properly anyway. + r := compileLikeToRegex("foo-bar") + require.True(t, r.MatchString("foo-bar")) + require.False(t, r.MatchString("foo.bar")) // would match if '-' leaked into a character class +} + +func TestACLCache_LookupOnNilReceiverSafe(t *testing.T) { + var c *aclCache + read, write, found := c.Lookup("phil", "mytopic") + require.False(t, found) + require.False(t, read) + require.False(t, write) +} + +func TestACLCache_LookupBeforeReload(t *testing.T) { + // Before reload the snapshot pointer is nil. The cache treats this as + // "no rule found", which the caller resolves via DefaultAccess. + c := newAccessCache() + read, write, found := c.Lookup("phil", "mytopic") + require.False(t, found) + require.False(t, read) + require.False(t, write) +} + +func TestACLCache_ExactMatchHit(t *testing.T) { + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: "phil", topic: "mytopic", read: true, write: true}, + })) + read, write, found := c.Lookup("phil", "mytopic") + require.True(t, found) + require.True(t, read) + require.True(t, write) +} + +func TestACLCache_ExactMatchMiss(t *testing.T) { + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: "phil", topic: "mytopic", read: true, write: true}, + })) + _, _, found := c.Lookup("phil", "othertopic") + require.False(t, found) +} + +func TestACLCache_LiteralUnderscoreExactMatch(t *testing.T) { + // Stored as "my\_topic" (toSQLWildcard of "my_topic"). A literal underscore + // in the requested topic must match, while any other single char must not. + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: "phil", topic: `my\_topic`, read: true, write: false}, + })) + read, write, found := c.Lookup("phil", "my_topic") + require.True(t, found) + require.True(t, read) + require.False(t, write) + + _, _, found = c.Lookup("phil", "myXtopic") + require.False(t, found) +} + +func TestACLCache_WildcardMatch(t *testing.T) { + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: Everyone, topic: "up%", read: false, write: true}, + })) + read, write, found := c.Lookup("phil", "up42") + require.True(t, found) + require.False(t, read) + require.True(t, write) +} + +func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) { + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: Everyone, topic: "mytopic", read: true, write: false}, + {user: "phil", topic: "mytopic", read: false, write: false}, // deny-all for phil + })) + read, write, found := c.Lookup("phil", "mytopic") + require.True(t, found) + require.False(t, read) + require.False(t, write) +} + +func TestACLCache_AnonymousReadsEveryone(t *testing.T) { + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: Everyone, topic: "announcements", read: true, write: false}, + })) + read, write, found := c.Lookup(Everyone, "announcements") + require.True(t, found) + require.True(t, read) + require.False(t, write) +} + +func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) { + // Both rules belong to the same user (Everyone). The more specific (longer) + // "mytopic%" should beat the catch-all "%". + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: Everyone, topic: "%", read: true, write: false}, + {user: Everyone, topic: "mytopic%", read: true, write: true}, + })) + read, write, found := c.Lookup(Everyone, "mytopicX") + require.True(t, found) + require.True(t, read) + require.True(t, write) +} + +func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) { + // Two wildcard rules of identical length for the same user. The write rule + // should win the tie-break. + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: Everyone, topic: "ab%", read: true, write: false}, + {user: Everyone, topic: "ab%", read: false, write: true}, // synthesized; impossible via real upsert but exercises the tie-break + })) + // One of the two will be the surviving exact-key entry (map collision keeps last); + // but the wildcard slice is what we want to exercise. Inject two wildcard entries + // directly to force the tie-break path. + c.snap.Store(&aclSnapshot{ + exact: map[string]map[string]aclEntry{}, + wildcards: map[string][]aclEntry{ + Everyone: { + {topic: "ab%", read: true, write: false, matcher: compileLikeToRegex("ab%")}, + {topic: "ab%", read: false, write: true, matcher: compileLikeToRegex("ab%")}, + }, + }, + }) + _, write, found := c.Lookup(Everyone, "abc") + require.True(t, found) + require.True(t, write) +} + +func TestACLCache_ConcurrentLookupAndReload(t *testing.T) { + // Atomic-pointer swap must be safe under concurrent reads. The race detector + // catches any unsafe shared mutation. + c := newAccessCache() + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: Everyone, topic: "mytopic", read: true, write: true}, + })) + + var stop atomic.Bool + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for !stop.Load() { + _, _, _ = c.Lookup(Everyone, "mytopic") + } + }() + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + c.snap.Store(buildSnapshot(t, []rawACLRow{ + {user: Everyone, topic: "mytopic", read: i%2 == 0, write: i%2 == 1}, + })) + } + stop.Store(true) + }() + wg.Wait() +} + +// rawACLRow + buildSnapshot mirror the rows that reload would Scan from the DB +// but avoid actually opening a DB for these unit tests. +type rawACLRow struct { + user string + topic string + read bool + write bool +} + +func buildSnapshot(t *testing.T, rows []rawACLRow) *aclSnapshot { + t.Helper() + snap := &aclSnapshot{ + exact: make(map[string]map[string]aclEntry), + wildcards: make(map[string][]aclEntry), + } + for _, r := range rows { + e := aclEntry{topic: r.topic, read: r.read, write: r.write} + if containsPercent(r.topic) { + e.matcher = compileLikeToRegex(r.topic) + snap.wildcards[r.user] = append(snap.wildcards[r.user], e) + } else { + if snap.exact[r.user] == nil { + snap.exact[r.user] = make(map[string]aclEntry) + } + snap.exact[r.user][r.topic] = e + } + } + return snap +} + +func containsPercent(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == '%' { + return true + } + } + return false +} diff --git a/user/manager.go b/user/manager.go index 303c7a49..52217e29 100644 --- a/user/manager.go +++ b/user/manager.go @@ -38,6 +38,11 @@ const ( const ( DefaultUserStatsQueueWriterInterval = 33 * time.Second DefaultUserPasswordBcryptCost = 10 + // DefaultAccessCacheReloadInterval bounds how stale the in-memory ACL snapshot + // can be relative to writes made by *other* processes (e.g. a separate `ntfy + // access` CLI invocation modifying the same database). Mutations performed + // by this Manager refresh the cache synchronously and do not depend on this. + DefaultAccessCacheReloadInterval = 5 * time.Second ) var ( @@ -48,12 +53,14 @@ var ( // Manager handles user authentication, authorization, and management type Manager struct { - config *Config - db *db.DB - queries queries - statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) - tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) - mu sync.Mutex + config *Config + db *db.DB + queries queries + statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) + tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) + accessCache *aclCache // In-memory snapshot of user_access; rebuilt after every ACL mutation + quit chan struct{} // Closed by Close() to signal background goroutines to stop + mu sync.Mutex } var _ Auther = (*Manager)(nil) @@ -65,20 +72,60 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { if config.QueueWriterInterval.Seconds() <= 0 { config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } + if config.AccessCacheReloadInterval == 0 { + config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval + } manager := &Manager{ - config: config, - db: d, - statsQueue: make(map[string]*Stats), - tokenQueue: make(map[string]*TokenUpdate), - queries: queries, + config: config, + db: d, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), + accessCache: newAccessCache(), + quit: make(chan struct{}), + queries: queries, } if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil { return nil, err } + // Populate the ACL cache after provisioning so the initial snapshot includes + // any provisioned access rules. Subsequent mutations call reloadAccessCache. + if err := manager.reloadAccessCache(); err != nil { + return nil, err + } go manager.asyncQueueWriter(manager.config.QueueWriterInterval) + if manager.config.AccessCacheReloadInterval > 0 { + go manager.asyncAccessCacheReloader(manager.config.AccessCacheReloadInterval) + } return manager, nil } +// reloadAccessCache rebuilds the in-memory ACL snapshot from the primary +// database. Called once at startup (after provisioning) and after every method +// that mutates user_access (directly or by cascade from "user" deletion). +func (a *Manager) reloadAccessCache() error { + return a.accessCache.reload(a.db, a.queries.selectAllAccessForCache) +} + +// asyncAccessCacheReloader periodically refreshes the ACL snapshot so that +// writes made by other processes against the same database (most notably the +// `ntfy access` CLI subcommand running while a server holds the cache) become +// visible within the configured interval. This Manager's own writes do not +// depend on the poller -- they refresh the cache synchronously. +func (a *Manager) asyncAccessCacheReloader(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-a.quit: + return + case <-ticker.C: + if err := a.reloadAccessCache(); err != nil { + log.Tag(tag).Err(err).Warn("Reloading ACL cache failed") + } + } + } +} + // Authenticate checks username and password and returns a User if correct, and the user has not been // marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or // the password is correct or incorrect. @@ -151,9 +198,13 @@ func (a *Manager) RemoveUser(username string) error { if err := a.CanChangeUser(username); err != nil { return err } - return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := db.ExecTx(a.db, func(tx *sql.Tx) error { return a.removeUserTx(tx, username) - }) + }); err != nil { + return err + } + // user_access rows are cascade-deleted along with the user; refresh the snapshot. + return a.reloadAccessCache() } // removeUserTx deletes the user with the given username @@ -174,7 +225,7 @@ func (a *Manager) MarkUserRemoved(user *User) error { if !AllowedUsername(user.Name) { return ErrInvalidArgument } - return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := db.ExecTx(a.db, func(tx *sql.Tx) error { if err := a.resetUserAccessTx(tx, user.Name); err != nil { return err } @@ -186,7 +237,11 @@ func (a *Manager) MarkUserRemoved(user *User) error { return err } return nil - }) + }); err != nil { + return err + } + // resetUserAccessTx wiped this user's user_access rows; refresh the snapshot. + return a.reloadAccessCache() } // RemoveDeletedUsers deletes all users that have been marked deleted @@ -194,7 +249,8 @@ func (a *Manager) RemoveDeletedUsers() error { if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil { return err } - return nil + // user_access rows are cascade-deleted with the users; refresh the snapshot. + return a.reloadAccessCache() } // ChangePassword changes a user's password @@ -225,9 +281,15 @@ func (a *Manager) ChangeRole(username string, role Role) error { if err := a.CanChangeUser(username); err != nil { return err } - return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := db.ExecTx(a.db, func(tx *sql.Tx) error { return a.changeRoleTx(tx, username, role) - }) + }); err != nil { + return err + } + // Promotion to admin clears user_access rows for the user; refresh the snapshot. + // Other role changes are no-ops for the cache but reloading is cheap and keeps + // the code path uniform. + return a.reloadAccessCache() } // changeRoleTx changes a user's role @@ -588,9 +650,12 @@ func (a *Manager) resolvePerms(base, perm Permission) error { // read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry // owner may either be a user (username), or the system (empty). func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { - return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := db.ExecTx(a.db, func(tx *sql.Tx) error { return a.allowAccessTx(tx, username, topicPattern, permission, false) - }) + }); err != nil { + return err + } + return a.reloadAccessCache() } func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error { @@ -606,9 +671,12 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is // empty) for an entire user. The parameter topicPattern may include wildcards (*). func (a *Manager) ResetAccess(username string, topicPattern string) error { - return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := db.ExecTx(a.db, func(tx *sql.Tx) error { return a.resetAccessTx(tx, username, topicPattern) - }) + }); err != nil { + return err + } + return a.reloadAccessCache() } func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error { @@ -650,24 +718,15 @@ func (a *Manager) AllowReservation(username string, topic string) error { // authorizeTopicAccess returns the read/write permissions for the given username and topic. // The found return value indicates whether an ACL entry was found at all. // -// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user. -// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" +// - The cache may contain two matching entries (one for everyone, and one for the user), but prioritizes the user. +// - Furthermore, the lookup prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" // - It also prioritizes write permissions over read permissions +// +// The lookup is served entirely from the in-memory snapshot maintained by accessCache, +// so this is on the hot path of every authenticatable HTTP request and must stay allocation-free. func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) { - rows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic) - if err != nil { - return false, false, false, err - } - defer rows.Close() - if !rows.Next() { - return false, false, false, nil - } - if err := rows.Scan(&read, &write); err != nil { - return false, false, false, err - } else if err := rows.Err(); err != nil { - return false, false, false, err - } - return read, write, true, nil + read, write, found = a.accessCache.Lookup(usernameOrEveryone, topic) + return read, write, found, nil } // AllGrants returns all user-specific access control entries, mapped to their respective user IDs @@ -731,7 +790,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { return ErrInvalidArgument } - return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := db.ExecTx(a.db, func(tx *sql.Tx) error { if limit > 0 { hasReservation, err := a.hasReservationTx(tx, username, topic) if err != nil { @@ -754,7 +813,10 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss return err } return nil - }) + }); err != nil { + return err + } + return a.reloadAccessCache() } // RemoveReservations deletes the access control entries associated with the given username/topic, @@ -769,14 +831,17 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { return ErrInvalidArgument } } - return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := db.ExecTx(a.db, func(tx *sql.Tx) error { for _, topic := range topics { if err := a.removeReservationAccessTx(tx, username, topic); err != nil { return err } } return nil - }) + }); err != nil { + return err + } + return a.reloadAccessCache() } // Reservations returns all user-owned topics, and the associated everyone-access @@ -1515,8 +1580,15 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string, return nil } -// Close closes the underlying database +// Close stops background goroutines and closes the underlying database. +// Safe to call multiple times. func (a *Manager) Close() error { + select { + case <-a.quit: + // already closed + default: + close(a.quit) + } return a.db.Close() } diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 02cffd84..d3bb6f5f 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -70,12 +70,10 @@ const ( postgresDeleteUsersProvisionedQuery = `DELETE FROM "user" WHERE provisioned = true` // Access queries - postgresSelectTopicPermsQuery = ` - SELECT read, write + postgresSelectAllAccessForCacheQuery = ` + SELECT u.user_name, a.topic, a.read, a.write FROM user_access a JOIN "user" u ON u.id = a.user_id - WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\' - ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC ` postgresSelectUserAllAccessQuery = ` SELECT user_id, topic, read, write, provisioned @@ -244,7 +242,7 @@ var postgresQueries = queries{ deleteUserTier: postgresDeleteUserTierQuery, deleteUsersMarked: postgresDeleteUsersMarkedQuery, deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery, - selectTopicPerms: postgresSelectTopicPermsQuery, + selectAllAccessForCache: postgresSelectAllAccessForCacheQuery, selectUserAllAccess: postgresSelectUserAllAccessQuery, selectUserAccess: postgresSelectUserAccessQuery, selectUserReservations: postgresSelectUserReservationsQuery, diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 0f1a9227..4d498ce2 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -76,12 +76,10 @@ const ( sqliteDeleteUsersProvisionedQuery = `DELETE FROM user WHERE provisioned = 1` // Access queries - sqliteSelectTopicPermsQuery = ` - SELECT read, write + sqliteSelectAllAccessForCacheQuery = ` + SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id - WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\' - ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC ` sqliteSelectUserAllAccessQuery = ` SELECT user_id, topic, read, write, provisioned @@ -242,7 +240,7 @@ var sqliteQueries = queries{ deleteUserTier: sqliteDeleteUserTierQuery, deleteUsersMarked: sqliteDeleteUsersMarkedQuery, deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery, - selectTopicPerms: sqliteSelectTopicPermsQuery, + selectAllAccessForCache: sqliteSelectAllAccessForCacheQuery, selectUserAllAccess: sqliteSelectUserAllAccessQuery, selectUserAccess: sqliteSelectUserAccessQuery, selectUserReservations: sqliteSelectUserReservationsQuery, diff --git a/user/types.go b/user/types.go index d0d40e33..6d2650ed 100644 --- a/user/types.go +++ b/user/types.go @@ -255,6 +255,16 @@ type Config struct { Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database BcryptCost int // Cost of generated passwords; lowering makes testing faster + + // AccessCacheReloadInterval bounds the staleness of the in-memory ACL cache + // relative to writes from other processes (e.g. `ntfy access` CLI against a + // running server). + // 0 -> use DefaultAccessCacheReloadInterval + // negative -> disable the background poller; cache only refreshes on this + // Manager's own ACL mutations. Use this for short-lived Managers + // (e.g. the CLI subcommands) where polling is wasted work. + // positive -> poll at the given interval + AccessCacheReloadInterval time.Duration } // Error constants used by the package @@ -303,7 +313,7 @@ type queries struct { deleteUsersProvisioned string // Access queries - selectTopicPerms string + selectAllAccessForCache string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache selectUserAllAccess string selectUserAccess string selectUserReservations string From fda636fe34de71308382829914579dc3d60a0aa8 Mon Sep 17 00:00:00 2001 From: s1m0 <4467591+simoneferrari@users.noreply.github.com> Date: Fri, 29 May 2026 00:24:43 +0300 Subject: [PATCH 456/548] Update integrations.md with addtional ntfy GUI --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index a78d46f8..ff08691a 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -89,6 +89,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [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 - [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy From ef0dde8aa4cadbc9da2dc6db76bc6008ffa04028 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 28 May 2026 21:58:01 -0400 Subject: [PATCH 457/548] PWA token extend --- web/public/sw.js | 25 +++++++++++++------------ web/src/app/events.js | 6 ++++-- web/src/components/hooks.js | 13 ++++++++----- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/web/public/sw.js b/web/public/sw.js index 0e408206..0ace286e 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -12,8 +12,9 @@ import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE, - WEBPUSH_EVENT_MESSAGE, - WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING, + SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG, + SW_WEBPUSH_EVENT_MESSAGE, + SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING, } from "../src/app/events"; /** @@ -88,11 +89,11 @@ const handlePushMessage = async (data) => { // Broadcast the message to potentially play a sound broadcastChannel.postMessage(message); - await extendToken(); + await maybeExtendToken(); }; -const refreshThreshold = 1000 * 60 * 60; // 1 hour -const extendToken = async () => { +const refreshTokenThreshold = 1000 * 60 * 60; // 1 hour +const maybeExtendToken = async () => { if (import.meta.env.DEV) { console.warn("[ServiceWorker] Skipping token extension in development since no config.base_url exists"); return; @@ -107,7 +108,7 @@ const extendToken = async () => { const lastExtendedAt = await session.lastExtendedAtAsync(); const now = Date.now(); - if (lastExtendedAt && now - lastExtendedAt < refreshThreshold) { + if (lastExtendedAt && now - lastExtendedAt < refreshTokenThreshold) { console.debug(`[ServiceWorker] Token extended ${Math.floor((now - lastExtendedAt) / 1000 / 60)} minutes ago, skipping`); return; } @@ -138,7 +139,7 @@ const extendToken = async () => { }; /** - * Registers a periodic-sync listener for `extend-token` (see hooks.js). + * Registers a periodic-sync listener for `extend_token` (see hooks.js). * This extends the token regardless of whether the browser is open. * * CAVEATS: @@ -147,9 +148,9 @@ const extendToken = async () => { * - Only when notifications are granted */ self.addEventListener("periodicsync", (event) => { - if (event.tag === "extend-token") { - console.log('[ServiceWorker] Received periodicsync event "extend-token"'); - event.waitUntil(extendToken()); + if (event.tag === SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG) { + console.log(`[ServiceWorker] Received periodicsync event "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`); + event.waitUntil(maybeExtendToken()); } }); @@ -263,7 +264,7 @@ const handlePush = async (data) => { // - Web app: hooks.js:handleNotification() // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... - if (data.event === WEBPUSH_EVENT_MESSAGE) { + if (data.event === SW_WEBPUSH_EVENT_MESSAGE) { const { message } = data; if (message.event === EVENT_MESSAGE) { return await handlePushMessage(data); @@ -272,7 +273,7 @@ const handlePush = async (data) => { } else if (message.event === EVENT_MESSAGE_CLEAR) { return await handlePushMessageClear(data); } - } else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) { + } else if (data.event === SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) { return await handlePushSubscriptionExpiring(data); } diff --git a/web/src/app/events.js b/web/src/app/events.js index d5c5ab88..65755106 100644 --- a/web/src/app/events.js +++ b/web/src/app/events.js @@ -8,8 +8,10 @@ export const EVENT_MESSAGE_DELETE = "message_delete"; export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_POLL_REQUEST = "poll_request"; -export const WEBPUSH_EVENT_MESSAGE = "message"; -export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; +export const SW_WEBPUSH_EVENT_MESSAGE = "message"; +export const SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; + +export const SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG = "extend_token"; // Check if an event is a notification event (message, delete, or read) export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index b7d0b6f1..19149931 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -13,7 +13,7 @@ import versionChecker from "../app/VersionChecker"; import { UnauthorizedError } from "../app/errors"; import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; -import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events"; +import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR, SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG } from "../app/events"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -284,13 +284,16 @@ export const useStandaloneWebPushAutoSubscribe = () => { }; /** - * Registers a periodicsync listener for `extend-token` (see sw.js). + * Registers a periodicsync listener for `extend_token` (see sw.js). * This extends the token regardless of whether the browser is open. * * CAVEATS: * - Chromium-only * - Only when the PWA is _installed_ (not just running in a browser tab) * - Only when notifications are granted + * + * This is an experimental feature: + * https://developer.mozilla.org/en-US/docs/Web/API/Web_Periodic_Background_Synchronization_API */ const usePeriodicTokenExtend = () => { const isLaunchedPWA = useIsLaunchedPWA(); @@ -315,9 +318,9 @@ const usePeriodicTokenExtend = () => { return; } - console.log(`[usePeriodicTokenExtend] Turning on periodicsync "extend-token"`); - await registration.periodicSync.register("extend-token", { - minInterval: 24 * 60 * 60 * 1000, // 24 hours + console.log(`[usePeriodicTokenExtend] Turning on periodicsync "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`); + await registration.periodicSync.register(SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG, { + minInterval: 60 * 1000 // 24 * 60 * 60 * 1000 // 24 hours }); } catch (error) { console.log("[usePeriodicTokenExtend] Periodic Sync could not be registered", error); From 0c819a003de85ae103918fe74e64cf12d744aea9 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 30 May 2026 12:35:51 -0400 Subject: [PATCH 458/548] Release notes --- docs/releases.md | 6 ++++++ web/src/components/hooks.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index ce6c3bf0..bcf901f0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1905,6 +1905,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +## ntfy server v2.24.0 (UNRELEASED) + +**Bug fixes + maintenance:** + +* Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution) + ## ntfy iOS app v1.7.0 (UNRELEASED) This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 19149931..750292ec 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -320,7 +320,7 @@ const usePeriodicTokenExtend = () => { console.log(`[usePeriodicTokenExtend] Turning on periodicsync "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`); await registration.periodicSync.register(SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG, { - minInterval: 60 * 1000 // 24 * 60 * 60 * 1000 // 24 hours + minInterval: 12 * 60 * 60 * 1000 // 12 hours }); } catch (error) { console.log("[usePeriodicTokenExtend] Periodic Sync could not be registered", error); From a3f0f6cfa040b3a2206f93b5d6633e6c9a7c4eab Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 30 May 2026 12:44:38 -0400 Subject: [PATCH 459/548] Dependabot protection --- .github/dependabot.yml | 22 +++++++++++++++++++++ Makefile | 2 +- docs/releases.md | 43 +++++++++++++++++++++--------------------- 3 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..7281e224 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/web" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/Makefile b/Makefile index a46f4dba..7bfd1cfc 100644 --- a/Makefile +++ b/Makefile @@ -151,7 +151,7 @@ web-deps: # If this fails for .svg files, optimize them with svgo web-deps-update: - cd web && $(NPM) update + cd web && $(NPM) update --before="$(shell date -d '7 days ago' +%Y-%m-%d)" cd web && $(NPM) install web-fmt: diff --git a/docs/releases.md b/docs/releases.md index bcf901f0..8ded9bba 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,10 +8,29 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release |------------------|---------|--------------| | ntfy server | v2.23.0 | May 17, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | -| ntfy iOS app | v1.6.0 | May 12, 2026 | +| ntfy iOS app | v1.7.0 | May 30, 2026 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy iOS app v1.7.0 +Released May 30, 2026 + +This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS +feature gaps. Images sent via the `Attach` header (or as a PUT body) are now previewed inline in the notification banner +and inside the app, and other attachments can be downloaded, previewed via Quick Look, and shared from the notification +row. There's also a new "Download attachments" setting to control auto-download by size. + +**Features:** + +* Show image previews in notifications and inline in the notification list, with tap-to-zoom Quick Look preview and share sheet ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), [#276](https://github.com/binwiederhier/ntfy/issues/276), [#1226](https://github.com/binwiederhier/ntfy/issues/1226), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Download non-image attachments on demand with progress indication, persist them locally, and reuse files already fetched by the notification service extension ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Add "Download attachments" setting with size thresholds (Never, Under 100 KB / 500 KB / 1 MB / 5 MB / 10 MB / 50 MB, Always) to control automatic attachment downloads ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) + +**Bug fixes + maintenance:** + +* Improve background download reliability so attachments continue downloading when the app is suspended ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Reorganize notification and subscription views into their own folders and split out `NotificationRowView` for readability ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) + ## ntfy server v2.23.0 Released May 17, 2026 @@ -1905,31 +1924,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -## ntfy server v2.24.0 (UNRELEASED) +### ntfy server v2.24.0 (UNRELEASED) **Bug fixes + maintenance:** * Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution) -## ntfy iOS app v1.7.0 (UNRELEASED) - -This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS -feature gaps. Images sent via the `Attach` header (or as a PUT body) are now previewed inline in the notification banner -and inside the app, and other attachments can be downloaded, previewed via Quick Look, and shared from the notification -row. There's also a new "Download attachments" setting to control auto-download by size. - -**Features:** - -* Show image previews in notifications and inline in the notification list, with tap-to-zoom Quick Look preview and share sheet ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), [#276](https://github.com/binwiederhier/ntfy/issues/276), [#1226](https://github.com/binwiederhier/ntfy/issues/1226), thanks to [@am7590](https://github.com/am7590) for the contribution) -* Download non-image attachments on demand with progress indication, persist them locally, and reuse files already fetched by the notification service extension ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) -* Add "Download attachments" setting with size thresholds (Never, Under 100 KB / 500 KB / 1 MB / 5 MB / 10 MB / 50 MB, Always) to control automatic attachment downloads ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) - -**Bug fixes + maintenance:** - -* Improve background download reliability so attachments continue downloading when the app is suspended ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) -* Reorganize notification and subscription views into their own folders and split out `NotificationRowView` for readability ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) - -## ntfy Android v1.25.x (UNRELEASED) +### ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings. From 9f217d9d40de755aa083de12a1916eafa75c564c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 30 May 2026 12:55:14 -0400 Subject: [PATCH 460/548] Bump --- go.mod | 39 +++++++++++------------ go.sum | 74 ++++++++++++++++++++++--------------------- web/package-lock.json | 55 +++++++++++++++++--------------- 3 files changed, 86 insertions(+), 82 deletions(-) diff --git a/go.mod b/go.mod index c3732a06..75d7c8d1 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module heckel.io/ntfy/v2 -go 1.25.0 +go 1.25.8 require ( cloud.google.com/go/firestore v1.22.0 // indirect - cloud.google.com/go/storage v1.62.1 // indirect + cloud.google.com/go/storage v1.62.2 // 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 @@ -14,12 +14,12 @@ require ( 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.51.0 + golang.org/x/crypto v0.52.0 golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 golang.org/x/time v0.15.0 - google.golang.org/api v0.279.0 + google.golang.org/api v0.282.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -34,7 +34,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/text v0.37.0 ) @@ -69,7 +69,7 @@ require ( 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.15 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -79,27 +79,26 @@ 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.67.5 // indirect + github.com/prometheus/common v0.68.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/net v0.54.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + 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.55.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 7010ba7b..ae92229d 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMh cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= 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.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= -cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= +cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= 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= @@ -96,8 +96,8 @@ 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.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= -github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= 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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -139,8 +139,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA= +github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -165,24 +165,26 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= -go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= @@ -193,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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= 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= @@ -209,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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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= @@ -234,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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.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= @@ -272,16 +274,16 @@ 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.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= -google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= 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-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554= -google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM= -google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= -google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE= +google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/web/package-lock.json b/web/package-lock.json index ebb55f34..a2fc6aaa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3293,9 +3293,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "license": "MIT", "peer": true, "dependencies": { @@ -3747,9 +3747,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", - "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4308,9 +4308,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.357", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", - "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "dev": true, "license": "ISC" }, @@ -4447,9 +4447,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -6830,11 +6830,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -7145,9 +7148,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7193,9 +7196,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -7213,7 +7216,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8326,9 +8329,9 @@ } }, "node_modules/terser": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz", - "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", "dependencies": { From 36ab5b3a8b8ce5719068ea388a704f48e10dbc6c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 30 May 2026 12:55:22 -0400 Subject: [PATCH 461/548] Fmt --- web/src/components/hooks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 750292ec..db832809 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -320,7 +320,7 @@ const usePeriodicTokenExtend = () => { console.log(`[usePeriodicTokenExtend] Turning on periodicsync "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`); await registration.periodicSync.register(SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG, { - minInterval: 12 * 60 * 60 * 1000 // 12 hours + minInterval: 12 * 60 * 60 * 1000, // 12 hours }); } catch (error) { console.log("[usePeriodicTokenExtend] Periodic Sync could not be registered", error); From 6596551bc183e296add1bda1bfaed7a43880332f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 16:56:19 +0000 Subject: [PATCH 462/548] Bump docker/login-action from 2 to 4 Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 4. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v2...v4) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3c959bb6..30d07071 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -37,7 +37,7 @@ jobs: cache: 'npm' cache-dependency-path: './web/package-lock.json' - name: Docker login - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ github.repository_owner }} password: ${{ secrets.DOCKER_HUB_TOKEN }} From b908213f02c9a050df900fa857fd743e3ee760e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 16:56:24 +0000 Subject: [PATCH 463/548] Bump actions/checkout from 3 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yaml | 2 +- .github/workflows/docs.yaml | 4 ++-- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ca44e4b6..fd104e91 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install Go uses: actions/setup-go@v4 with: diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 6991dea6..c4fd94f9 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -9,10 +9,10 @@ jobs: steps: - name: Checkout ntfy code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Checkout docs pages code - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: repository: binwiederhier/ntfy-docs.github.io path: build/ntfy-docs.github.io diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3c959bb6..929ea85b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -25,7 +25,7 @@ jobs: NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install Go uses: actions/setup-go@v4 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4d6bbbdb..d042d28d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -25,7 +25,7 @@ jobs: NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install Go uses: actions/setup-go@v4 with: From f55886e3ccadeaf7da40f7ed6dde1c4a4caf000b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 30 May 2026 13:07:41 -0400 Subject: [PATCH 464/548] Pipeline fixes --- .github/workflows/build.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ca44e4b6..fca7071a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,7 +12,7 @@ jobs: - name: Install Go uses: actions/setup-go@v4 with: - go-version: '1.25.x' + go-version: '1.26.x' - name: Install node uses: actions/setup-node@v3 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3c959bb6..7f6bb6c3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -29,7 +29,7 @@ jobs: - name: Install Go uses: actions/setup-go@v4 with: - go-version: '1.25.x' + go-version: '1.26.x' - name: Install node uses: actions/setup-node@v3 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4d6bbbdb..1c54bab0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -29,7 +29,7 @@ jobs: - name: Install Go uses: actions/setup-go@v4 with: - go-version: '1.25.x' + go-version: '1.26.x' - name: Install node uses: actions/setup-node@v3 with: From 4dd7d1cd6225b7ef48becbdc4c95a7bf138bb799 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 17:09:50 +0000 Subject: [PATCH 465/548] Bump actions/setup-node from 3 to 6 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v3...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index fca7071a..f19639cb 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -14,7 +14,7 @@ jobs: with: go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: '24' cache: 'npm' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7f6bb6c3..cb7a0239 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -31,7 +31,7 @@ jobs: with: go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: '24' cache: 'npm' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1c54bab0..122c402a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -31,7 +31,7 @@ jobs: with: go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: '24' cache: 'npm' From 22154792945fd02d858619709899fc6585ddcc5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 17:11:55 +0000 Subject: [PATCH 466/548] Bump actions/setup-go from 4 to 6 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 6. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4ef26c7b..7cb93f7e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -10,7 +10,7 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: go-version: '1.26.x' - name: Install node diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e864ca44..a4e551f6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -27,7 +27,7 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: go-version: '1.26.x' - name: Install node diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 01af33a8..97422a57 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -27,7 +27,7 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: go-version: '1.26.x' - name: Install node From 561a44b29b3264b13043212f2595015dd996b46e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 30 May 2026 13:23:06 -0400 Subject: [PATCH 467/548] Docs --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index 8ded9bba..df18add5 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1929,6 +1929,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** * Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution) +* Fix `rel` attribute on auto-linked notification URLs so `noreferrer`/`noopener` are actually applied ([#1720](https://github.com/binwiederhier/ntfy/pull/1720), thanks to [@dmitrylyzo](https://github.com/dmitrylyzo) for the contribution) ### ntfy Android v1.25.x (UNRELEASED) From c51a3c0cb10f228fc535507f0c4c49ec5ffd36d9 Mon Sep 17 00:00:00 2001 From: Nihal Gonsalves Date: Sat, 30 May 2026 23:32:41 +0200 Subject: [PATCH 468/548] security: pin GitHub Actions --- .github/workflows/build.yaml | 6 +++--- .github/workflows/docs.yaml | 4 ++-- .github/workflows/release.yaml | 8 ++++---- .github/workflows/test.yaml | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d56a7b1b..5453fb3a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,13 +8,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' cache: 'npm' diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index c4fd94f9..da1e59ea 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -9,10 +9,10 @@ jobs: steps: - name: Checkout ntfy code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Checkout docs pages code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: binwiederhier/ntfy-docs.github.io path: build/ntfy-docs.github.io diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 12d2f5c8..db8f15f5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -25,19 +25,19 @@ jobs: NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' cache: 'npm' cache-dependency-path: './web/package-lock.json' - name: Docker login - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ github.repository_owner }} password: ${{ secrets.DOCKER_HUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 69735a15..ead55ba6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -25,13 +25,13 @@ jobs: NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: '1.26.x' - name: Install node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' cache: 'npm' From 2d2b1635fe81c55a9c6889b843b9058328d5fef0 Mon Sep 17 00:00:00 2001 From: Nihal Gonsalves Date: Sat, 30 May 2026 23:34:18 +0200 Subject: [PATCH 469/548] chore: group github action updates --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7281e224..6507eadc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,3 +20,7 @@ updates: interval: "weekly" cooldown: default-days: 7 + groups: + all: + patterns: + - "*" From 6310e3a96f4baaf7585e1c67427c8bf70bd3f565 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 10:45:10 -0400 Subject: [PATCH 470/548] Replace atomic.Pointer for readbility --- user/access_cache.go | 128 ++++++++++++++++++-------------------- user/access_cache_test.go | 119 ++++++++++++++++++----------------- 2 files changed, 125 insertions(+), 122 deletions(-) diff --git a/user/access_cache.go b/user/access_cache.go index 98942d05..d73f48a1 100644 --- a/user/access_cache.go +++ b/user/access_cache.go @@ -3,105 +3,101 @@ package user import ( "regexp" "strings" - "sync/atomic" + "sync" "heckel.io/ntfy/v2/db" ) -// aclEntry mirrors one user_access row in the in-memory snapshot. +// aclCache is an in-memory index over the entire user_access table. +// +// exact[username][escapedTopic] returns the matching entry in O(1) for the common +// case where the requested topic appears verbatim in some rule. The key is the +// stored form of the topic (i.e. with \_ escapes), so Lookup escapes incoming +// topics through escapeUnderscore before probing. +// +// wildcard[username] is the linear-scan list of %-bearing rules for that user. +// Walked per request; trivially small in practice. Wildcards are NOT u_everyone- +// only -- any user can create them. +type aclCache struct { + exact map[string]map[string]aclEntry + wildcard map[string][]aclEntry + mu sync.RWMutex // Protect exact and wildcard +} + +// aclEntry mirrors one user_access row in the in-memory cache. // // topic is the raw stored value: it may contain \_ escapes (for literal underscores) // and % wildcards (translated from user-supplied *). For exact-match entries (no %) -// matcher is nil and the entry is keyed by topic in aclSnapshot.exact. For wildcard +// matcher is nil and the entry is keyed by topic in aclCache.exact. For wildcard // entries (with %) matcher is the pre-compiled regex equivalent of the LIKE pattern. type aclEntry struct { topic string read bool write bool - matcher *regexp.Regexp -} - -// aclSnapshot is an immutable indexed form of the entire user_access table. -// -// exact[userName][escapedTopic] returns the matching entry in O(1) for the common -// case where the requested topic appears verbatim in some rule. The key is the -// stored form of the topic (i.e. with \_ escapes), so callers must pass topics -// through escapeUnderscore before probing. -// -// wildcards[userName] is the linear scan list of %-bearing rules for that user. -// Walked per request; trivially small in practice. Wildcards are NOT u_everyone- -// only -- any user can create them. -type aclSnapshot struct { - exact map[string]map[string]aclEntry - wildcards map[string][]aclEntry -} - -// aclCache holds the current snapshot behind an atomic pointer so that the hot -// path (Lookup) is lock-free. reload builds a fresh snapshot off the request -// path and atomically swaps the pointer; the old snapshot is GC'd once in-flight -// Lookups release their references. -// -// A nil receiver behaves as if no snapshot were loaded -- Lookup returns -// found=false, which the caller then resolves via DefaultAccess. This keeps -// tests and edge cases (e.g. early-startup) safe. -type aclCache struct { - snap atomic.Pointer[aclSnapshot] + matcher *regexp.Regexp // Nil for exact entries } func newAccessCache() *aclCache { - return &aclCache{} + return &aclCache{ + exact: make(map[string]map[string]aclEntry), + wildcard: make(map[string][]aclEntry), + } } -// reload runs the bulk-load query against the primary and atomically swaps in -// a fresh snapshot. The primary is used (not ReadOnly) so a reload immediately -// after an ACL mutation sees the freshly-written rows without replica lag. +// reload runs the bulk-load query against the primary and swaps in freshly-built +// exact and wildcard maps under the write lock. The primary is used (not +// ReadOnly) so a reload immediately after an ACL mutation sees the freshly- +// written rows without replica lag. func (c *aclCache) reload(d *db.DB, query string) error { rows, err := d.Query(query) if err != nil { return err } defer rows.Close() - snap := &aclSnapshot{ - exact: make(map[string]map[string]aclEntry), - wildcards: make(map[string][]aclEntry), - } + exact := make(map[string]map[string]aclEntry) + wildcards := make(map[string][]aclEntry) for rows.Next() { - var userName string + var username string var entry aclEntry - if err := rows.Scan(&userName, &entry.topic, &entry.read, &entry.write); err != nil { + if err := rows.Scan(&username, &entry.topic, &entry.read, &entry.write); err != nil { return err } if strings.Contains(entry.topic, "%") { - entry.matcher = compileLikeToRegex(entry.topic) - snap.wildcards[userName] = append(snap.wildcards[userName], entry) - } else { - if snap.exact[userName] == nil { - snap.exact[userName] = make(map[string]aclEntry) + re, err := compileLikeToRegex(entry.topic) + if err != nil { + return err } - snap.exact[userName][entry.topic] = entry + entry.matcher = re + wildcards[username] = append(wildcards[username], entry) + } else { + if exact[username] == nil { + exact[username] = make(map[string]aclEntry) + } + exact[username][entry.topic] = entry } } if err := rows.Err(); err != nil { return err } - c.snap.Store(snap) + c.mu.Lock() + c.exact = exact + c.wildcard = wildcards + c.mu.Unlock() return nil } // Lookup returns the effective (read, write, found) permission for the given // (username, topic), preserving the priority ordering of the original SQL query: -// 1. specific user beats Everyone -// 2. longer pattern beats shorter (more specific wins) -// 3. write beats read at equal length (write is "stronger") +// 1. specific user beats Everyone +// 2. longer pattern beats shorter (more specific wins) +// 3. write beats read at equal length (write is "stronger") func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found bool) { if c == nil { return false, false, false } - snap := c.snap.Load() - if snap == nil { - return false, false, false - } - // Pre-compute the escaped form once: exact-match keys in the snapshot are + c.mu.RLock() + defer c.mu.RUnlock() + // Pre-compute the escaped form once: exact-match keys in the cache are // stored as toSQLWildcard would emit them (literal _ -> \_), so the // incoming topic must be escaped the same way before map lookup. escaped := escapeUnderscore(topic) @@ -109,28 +105,28 @@ func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found // Specific user takes priority over Everyone. Skip the first lookup when // the request is already anonymous to avoid scanning the same map twice. if usernameOrEveryone != Everyone { - if e, ok := pickBest(snap, usernameOrEveryone, topic, escaped); ok { + if e, ok := c.pickBestLocked(usernameOrEveryone, topic, escaped); ok { return e.read, e.write, true } } - if e, ok := pickBest(snap, Everyone, topic, escaped); ok { + if e, ok := c.pickBestLocked(Everyone, topic, escaped); ok { return e.read, e.write, true } return false, false, false } -// pickBest returns the highest-priority entry for a single user, combining the -// exact-match O(1) probe with a linear scan over the (usually empty or tiny) -// wildcard list. Priority within a user: longer pattern wins; write wins ties. -func pickBest(snap *aclSnapshot, userName, topic, escaped string) (aclEntry, bool) { +// pickBestLocked returns the highest-priority entry for a single user, combining +// the exact-match O(1) probe with a linear scan over the (usually empty or tiny) +// wildcard list. Caller must hold c.mu (RLock is sufficient). +func (c *aclCache) pickBestLocked(username, topic, escaped string) (aclEntry, bool) { var best aclEntry var found bool - if m, ok := snap.exact[userName]; ok { + if m, ok := c.exact[username]; ok { if e, ok := m[escaped]; ok { best, found = e, true } } - for _, w := range snap.wildcards[userName] { + for _, w := range c.wildcard[username] { if !w.matcher.MatchString(topic) { continue } @@ -158,7 +154,7 @@ func better(a, b aclEntry) bool { // \_ is a literal underscore; no other backslashes occur. Topics themselves are // restricted to [A-Za-z0-9_-] (see AllowedTopic), so neither % nor stray // backslashes appear in user-supplied input. -func compileLikeToRegex(pattern string) *regexp.Regexp { +func compileLikeToRegex(pattern string) (*regexp.Regexp, error) { var sb strings.Builder sb.WriteString("^") i := 0 @@ -176,5 +172,5 @@ func compileLikeToRegex(pattern string) *regexp.Regexp { } } sb.WriteString("$") - return regexp.MustCompile(sb.String()) + return regexp.Compile(sb.String()) } diff --git a/user/access_cache_test.go b/user/access_cache_test.go index d8f11d71..6c617a0c 100644 --- a/user/access_cache_test.go +++ b/user/access_cache_test.go @@ -1,6 +1,7 @@ package user import ( + "regexp" "sync" "sync/atomic" "testing" @@ -14,21 +15,21 @@ import ( // forEachStoreBackend. func TestCompileLikeToRegex_Exact(t *testing.T) { - r := compileLikeToRegex("foo") + r := mustCompileLikeToRegex(t, "foo") require.True(t, r.MatchString("foo")) require.False(t, r.MatchString("foox")) require.False(t, r.MatchString("xfoo")) } func TestCompileLikeToRegex_TrailingPercent(t *testing.T) { - r := compileLikeToRegex("up%") + r := mustCompileLikeToRegex(t, "up%") require.True(t, r.MatchString("up")) require.True(t, r.MatchString("up123")) require.False(t, r.MatchString("xup")) } func TestCompileLikeToRegex_LeadingAndEmbeddedPercent(t *testing.T) { - r := compileLikeToRegex("%test%") + r := mustCompileLikeToRegex(t, "%test%") require.True(t, r.MatchString("test")) require.True(t, r.MatchString("mytest")) require.True(t, r.MatchString("testxxx")) @@ -39,7 +40,7 @@ func TestCompileLikeToRegex_LeadingAndEmbeddedPercent(t *testing.T) { func TestCompileLikeToRegex_EscapedUnderscore(t *testing.T) { // "my\_topic" is the stored form of a literal "my_topic" -- the underscore // must match itself, NOT act as a SQL one-character wildcard. - r := compileLikeToRegex(`my\_topic`) + r := mustCompileLikeToRegex(t, `my\_topic`) require.True(t, r.MatchString("my_topic")) require.False(t, r.MatchString("myXtopic")) require.False(t, r.MatchString("mytopic")) @@ -48,7 +49,7 @@ func TestCompileLikeToRegex_EscapedUnderscore(t *testing.T) { func TestCompileLikeToRegex_EscapedUnderscoreAdjacentToPercent(t *testing.T) { // "nz\_vip\_%" is the stored form of "nz_vip_*" -- literal "nz_vip_" prefix // followed by any suffix. - r := compileLikeToRegex(`nz\_vip\_%`) + r := mustCompileLikeToRegex(t, `nz\_vip\_%`) require.True(t, r.MatchString("nz_vip_")) require.True(t, r.MatchString("nz_vip_alpha")) require.False(t, r.MatchString("nz_vipX")) @@ -58,7 +59,7 @@ func TestCompileLikeToRegex_EscapedUnderscoreAdjacentToPercent(t *testing.T) { func TestCompileLikeToRegex_RegexMetaCharsInTopic(t *testing.T) { // Topics in ntfy can include '-', which is benign, but make sure // regex metacharacters in the pattern are escaped properly anyway. - r := compileLikeToRegex("foo-bar") + r := mustCompileLikeToRegex(t, "foo-bar") require.True(t, r.MatchString("foo-bar")) require.False(t, r.MatchString("foo.bar")) // would match if '-' leaked into a character class } @@ -72,8 +73,9 @@ func TestACLCache_LookupOnNilReceiverSafe(t *testing.T) { } func TestACLCache_LookupBeforeReload(t *testing.T) { - // Before reload the snapshot pointer is nil. The cache treats this as - // "no rule found", which the caller resolves via DefaultAccess. + // A freshly-constructed cache has empty exact and wildcards maps. The + // cache treats this as "no rule found", which the caller resolves via + // DefaultAccess. c := newAccessCache() read, write, found := c.Lookup("phil", "mytopic") require.False(t, found) @@ -83,9 +85,9 @@ func TestACLCache_LookupBeforeReload(t *testing.T) { func TestACLCache_ExactMatchHit(t *testing.T) { c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: "phil", topic: "mytopic", read: true, write: true}, - })) + }) read, write, found := c.Lookup("phil", "mytopic") require.True(t, found) require.True(t, read) @@ -94,9 +96,9 @@ func TestACLCache_ExactMatchHit(t *testing.T) { func TestACLCache_ExactMatchMiss(t *testing.T) { c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: "phil", topic: "mytopic", read: true, write: true}, - })) + }) _, _, found := c.Lookup("phil", "othertopic") require.False(t, found) } @@ -105,9 +107,9 @@ func TestACLCache_LiteralUnderscoreExactMatch(t *testing.T) { // Stored as "my\_topic" (toSQLWildcard of "my_topic"). A literal underscore // in the requested topic must match, while any other single char must not. c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: "phil", topic: `my\_topic`, read: true, write: false}, - })) + }) read, write, found := c.Lookup("phil", "my_topic") require.True(t, found) require.True(t, read) @@ -119,9 +121,9 @@ func TestACLCache_LiteralUnderscoreExactMatch(t *testing.T) { func TestACLCache_WildcardMatch(t *testing.T) { c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: Everyone, topic: "up%", read: false, write: true}, - })) + }) read, write, found := c.Lookup("phil", "up42") require.True(t, found) require.False(t, read) @@ -130,10 +132,10 @@ func TestACLCache_WildcardMatch(t *testing.T) { func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) { c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: Everyone, topic: "mytopic", read: true, write: false}, {user: "phil", topic: "mytopic", read: false, write: false}, // deny-all for phil - })) + }) read, write, found := c.Lookup("phil", "mytopic") require.True(t, found) require.False(t, read) @@ -142,9 +144,9 @@ func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) { func TestACLCache_AnonymousReadsEveryone(t *testing.T) { c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: Everyone, topic: "announcements", read: true, write: false}, - })) + }) read, write, found := c.Lookup(Everyone, "announcements") require.True(t, found) require.True(t, read) @@ -155,10 +157,10 @@ func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) { // Both rules belong to the same user (Everyone). The more specific (longer) // "mytopic%" should beat the catch-all "%". c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: Everyone, topic: "%", read: true, write: false}, {user: Everyone, topic: "mytopic%", read: true, write: true}, - })) + }) read, write, found := c.Lookup(Everyone, "mytopicX") require.True(t, found) require.True(t, read) @@ -167,36 +169,31 @@ func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) { func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) { // Two wildcard rules of identical length for the same user. The write rule - // should win the tie-break. + // should win the tie-break. The two-rows-with-same-topic shape is + // impossible via real upsert (pkey would conflict), so we inject the entries + // directly into the cache's wildcard slice. c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ - {user: Everyone, topic: "ab%", read: true, write: false}, - {user: Everyone, topic: "ab%", read: false, write: true}, // synthesized; impossible via real upsert but exercises the tie-break - })) - // One of the two will be the surviving exact-key entry (map collision keeps last); - // but the wildcard slice is what we want to exercise. Inject two wildcard entries - // directly to force the tie-break path. - c.snap.Store(&aclSnapshot{ - exact: map[string]map[string]aclEntry{}, - wildcards: map[string][]aclEntry{ - Everyone: { - {topic: "ab%", read: true, write: false, matcher: compileLikeToRegex("ab%")}, - {topic: "ab%", read: false, write: true, matcher: compileLikeToRegex("ab%")}, - }, + c.mu.Lock() + c.exact = map[string]map[string]aclEntry{} + c.wildcard = map[string][]aclEntry{ + Everyone: { + {topic: "ab%", read: true, write: false, matcher: mustCompileLikeToRegex(t, "ab%")}, + {topic: "ab%", read: false, write: true, matcher: mustCompileLikeToRegex(t, "ab%")}, }, - }) + } + c.mu.Unlock() _, write, found := c.Lookup(Everyone, "abc") require.True(t, found) require.True(t, write) } func TestACLCache_ConcurrentLookupAndReload(t *testing.T) { - // Atomic-pointer swap must be safe under concurrent reads. The race detector + // Lock-based swap must be safe under concurrent reads. The race detector // catches any unsafe shared mutation. c := newAccessCache() - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: Everyone, topic: "mytopic", read: true, write: true}, - })) + }) var stop atomic.Bool var wg sync.WaitGroup @@ -210,17 +207,17 @@ func TestACLCache_ConcurrentLookupAndReload(t *testing.T) { go func() { defer wg.Done() for i := 0; i < 100; i++ { - c.snap.Store(buildSnapshot(t, []rawACLRow{ + loadCache(t, c, []rawACLRow{ {user: Everyone, topic: "mytopic", read: i%2 == 0, write: i%2 == 1}, - })) + }) } stop.Store(true) }() wg.Wait() } -// rawACLRow + buildSnapshot mirror the rows that reload would Scan from the DB -// but avoid actually opening a DB for these unit tests. +// rawACLRow models the rows that reload would Scan from the DB but avoids +// actually opening a DB for these unit tests. type rawACLRow struct { user string topic string @@ -228,25 +225,35 @@ type rawACLRow struct { write bool } -func buildSnapshot(t *testing.T, rows []rawACLRow) *aclSnapshot { +// loadCache writes the given rows into the cache under its write lock, +// preserving the same exact/wildcard partitioning that reload would produce. +func loadCache(t *testing.T, c *aclCache, rows []rawACLRow) { t.Helper() - snap := &aclSnapshot{ - exact: make(map[string]map[string]aclEntry), - wildcards: make(map[string][]aclEntry), - } + exact := make(map[string]map[string]aclEntry) + wildcards := make(map[string][]aclEntry) for _, r := range rows { e := aclEntry{topic: r.topic, read: r.read, write: r.write} if containsPercent(r.topic) { - e.matcher = compileLikeToRegex(r.topic) - snap.wildcards[r.user] = append(snap.wildcards[r.user], e) + e.matcher = mustCompileLikeToRegex(t, r.topic) + wildcards[r.user] = append(wildcards[r.user], e) } else { - if snap.exact[r.user] == nil { - snap.exact[r.user] = make(map[string]aclEntry) + if exact[r.user] == nil { + exact[r.user] = make(map[string]aclEntry) } - snap.exact[r.user][r.topic] = e + exact[r.user][r.topic] = e } } - return snap + c.mu.Lock() + c.exact = exact + c.wildcard = wildcards + c.mu.Unlock() +} + +func mustCompileLikeToRegex(t *testing.T, pattern string) *regexp.Regexp { + t.Helper() + r, err := compileLikeToRegex(pattern) + require.NoError(t, err) + return r } func containsPercent(s string) bool { From c841caa3b335884c3f13dd10e0cf46cb17efddaf Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 11:05:27 -0400 Subject: [PATCH 471/548] Review, rename to pattern --- user/access_cache.go | 105 ++++++++++++++++++-------------------- user/access_cache_test.go | 12 ++--- 2 files changed, 57 insertions(+), 60 deletions(-) diff --git a/user/access_cache.go b/user/access_cache.go index d73f48a1..adcf6e02 100644 --- a/user/access_cache.go +++ b/user/access_cache.go @@ -15,37 +15,43 @@ import ( // stored form of the topic (i.e. with \_ escapes), so Lookup escapes incoming // topics through escapeUnderscore before probing. // -// wildcard[username] is the linear-scan list of %-bearing rules for that user. +// pattern[username] is the linear-scan list of %-bearing rules for that user. // Walked per request; trivially small in practice. Wildcards are NOT u_everyone- // only -- any user can create them. type aclCache struct { - exact map[string]map[string]aclEntry - wildcard map[string][]aclEntry - mu sync.RWMutex // Protect exact and wildcard + exact map[string]map[string]aclEntry + pattern map[string][]aclEntry + mu sync.RWMutex // Protect exact and pattern } // aclEntry mirrors one user_access row in the in-memory cache. // -// topic is the raw stored value: it may contain \_ escapes (for literal underscores) -// and % wildcards (translated from user-supplied *). For exact-match entries (no %) -// matcher is nil and the entry is keyed by topic in aclCache.exact. For wildcard -// entries (with %) matcher is the pre-compiled regex equivalent of the LIKE pattern. +// length is the length of the original stored value (topic for exact rows, +// SQL LIKE pattern for wildcard rows). It is only used by better() to +// implement the "longer pattern beats shorter" tie-break from the original +// SQL ORDER BY. The string itself is intentionally not stored on the entry: +// the exact map already keys on it, and surfacing it would invite misuse +// (wildcard "topics" are actually SQL patterns like "up%"). +// +// pattern is the pre-compiled regex equivalent of the stored LIKE pattern. +// For exact-match entries (no % in the stored value) pattern is nil and the +// entry is reachable only through aclCache.exact[username][topic]. type aclEntry struct { - topic string + length int // len() of the original stored topic/pattern + pattern *regexp.Regexp // nil for exact entries read bool write bool - matcher *regexp.Regexp // Nil for exact entries } func newAccessCache() *aclCache { return &aclCache{ - exact: make(map[string]map[string]aclEntry), - wildcard: make(map[string][]aclEntry), + exact: make(map[string]map[string]aclEntry), + pattern: make(map[string][]aclEntry), } } // reload runs the bulk-load query against the primary and swaps in freshly-built -// exact and wildcard maps under the write lock. The primary is used (not +// exact and pattern maps under the write lock. The primary is used (not // ReadOnly) so a reload immediately after an ACL mutation sees the freshly- // written rows without replica lag. func (c *aclCache) reload(d *db.DB, query string) error { @@ -54,34 +60,35 @@ func (c *aclCache) reload(d *db.DB, query string) error { return err } defer rows.Close() - exact := make(map[string]map[string]aclEntry) - wildcards := make(map[string][]aclEntry) + exacts := make(map[string]map[string]aclEntry) + patterns := make(map[string][]aclEntry) for rows.Next() { - var username string + var username, topic string var entry aclEntry - if err := rows.Scan(&username, &entry.topic, &entry.read, &entry.write); err != nil { + if err := rows.Scan(&username, &topic, &entry.read, &entry.write); err != nil { return err } - if strings.Contains(entry.topic, "%") { - re, err := compileLikeToRegex(entry.topic) + entry.length = len(topic) + if strings.Contains(topic, "%") { + re, err := compileLikeToRegex(topic) if err != nil { return err } - entry.matcher = re - wildcards[username] = append(wildcards[username], entry) + entry.pattern = re + patterns[username] = append(patterns[username], entry) } else { - if exact[username] == nil { - exact[username] = make(map[string]aclEntry) + if exacts[username] == nil { + exacts[username] = make(map[string]aclEntry) } - exact[username][entry.topic] = entry + exacts[username][topic] = entry } } if err := rows.Err(); err != nil { return err } c.mu.Lock() - c.exact = exact - c.wildcard = wildcards + c.exact = exacts + c.pattern = patterns c.mu.Unlock() return nil } @@ -92,56 +99,46 @@ func (c *aclCache) reload(d *db.DB, query string) error { // 2. longer pattern beats shorter (more specific wins) // 3. write beats read at equal length (write is "stronger") func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found bool) { - if c == nil { - return false, false, false - } + escapedTopic := escapeUnderscore(topic) c.mu.RLock() defer c.mu.RUnlock() - // Pre-compute the escaped form once: exact-match keys in the cache are - // stored as toSQLWildcard would emit them (literal _ -> \_), so the - // incoming topic must be escaped the same way before map lookup. - escaped := escapeUnderscore(topic) - - // Specific user takes priority over Everyone. Skip the first lookup when - // the request is already anonymous to avoid scanning the same map twice. if usernameOrEveryone != Everyone { - if e, ok := c.pickBestLocked(usernameOrEveryone, topic, escaped); ok { - return e.read, e.write, true + if entry, ok := c.pickBestNoLock(usernameOrEveryone, topic, escapedTopic); ok { + return entry.read, entry.write, true } } - if e, ok := c.pickBestLocked(Everyone, topic, escaped); ok { - return e.read, e.write, true + if entry, ok := c.pickBestNoLock(Everyone, topic, escapedTopic); ok { + return entry.read, entry.write, true } return false, false, false } -// pickBestLocked returns the highest-priority entry for a single user, combining +// pickBestNoLock returns the highest-priority entry for a single user, combining // the exact-match O(1) probe with a linear scan over the (usually empty or tiny) -// wildcard list. Caller must hold c.mu (RLock is sufficient). -func (c *aclCache) pickBestLocked(username, topic, escaped string) (aclEntry, bool) { +// pattern list. Caller must hold c.mu (RLock is sufficient). +func (c *aclCache) pickBestNoLock(username, topic, escapedTopic string) (*aclEntry, bool) { var best aclEntry var found bool - if m, ok := c.exact[username]; ok { - if e, ok := m[escaped]; ok { - best, found = e, true + if m, exists := c.exact[username]; exists { + if entry, exists := m[escapedTopic]; exists { + best, found = entry, true } } - for _, w := range c.wildcard[username] { - if !w.matcher.MatchString(topic) { + for _, pattern := range c.pattern[username] { + if !pattern.pattern.MatchString(topic) { continue - } - if !found || better(w, best) { - best, found = w, true + } else if !found || better(pattern, best) { + best, found = pattern, true } } - return best, found + return &best, found } // better implements the (length DESC, write DESC) tie-break used by the original // query's ORDER BY for entries owned by the same user. func better(a, b aclEntry) bool { - if len(a.topic) != len(b.topic) { - return len(a.topic) > len(b.topic) + if a.length != b.length { + return a.length > b.length } if a.write != b.write { return a.write diff --git a/user/access_cache_test.go b/user/access_cache_test.go index 6c617a0c..54d8d071 100644 --- a/user/access_cache_test.go +++ b/user/access_cache_test.go @@ -175,10 +175,10 @@ func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) { c := newAccessCache() c.mu.Lock() c.exact = map[string]map[string]aclEntry{} - c.wildcard = map[string][]aclEntry{ + c.pattern = map[string][]aclEntry{ Everyone: { - {topic: "ab%", read: true, write: false, matcher: mustCompileLikeToRegex(t, "ab%")}, - {topic: "ab%", read: false, write: true, matcher: mustCompileLikeToRegex(t, "ab%")}, + {length: len("ab%"), read: true, write: false, pattern: mustCompileLikeToRegex(t, "ab%")}, + {length: len("ab%"), read: false, write: true, pattern: mustCompileLikeToRegex(t, "ab%")}, }, } c.mu.Unlock() @@ -232,9 +232,9 @@ func loadCache(t *testing.T, c *aclCache, rows []rawACLRow) { exact := make(map[string]map[string]aclEntry) wildcards := make(map[string][]aclEntry) for _, r := range rows { - e := aclEntry{topic: r.topic, read: r.read, write: r.write} + e := aclEntry{length: len(r.topic), read: r.read, write: r.write} if containsPercent(r.topic) { - e.matcher = mustCompileLikeToRegex(t, r.topic) + e.pattern = mustCompileLikeToRegex(t, r.topic) wildcards[r.user] = append(wildcards[r.user], e) } else { if exact[r.user] == nil { @@ -245,7 +245,7 @@ func loadCache(t *testing.T, c *aclCache, rows []rawACLRow) { } c.mu.Lock() c.exact = exact - c.wildcard = wildcards + c.pattern = wildcards c.mu.Unlock() } From d987796243e732232658ec89ed63344d595084db Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 11:08:30 -0400 Subject: [PATCH 472/548] Rename --- user/access_cache.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/user/access_cache.go b/user/access_cache.go index adcf6e02..5c5359fd 100644 --- a/user/access_cache.go +++ b/user/access_cache.go @@ -119,8 +119,8 @@ func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found func (c *aclCache) pickBestNoLock(username, topic, escapedTopic string) (*aclEntry, bool) { var best aclEntry var found bool - if m, exists := c.exact[username]; exists { - if entry, exists := m[escapedTopic]; exists { + if exact, exists := c.exact[username]; exists { + if entry, exists := exact[escapedTopic]; exists { best, found = entry, true } } From 4b87a2732633e3a6e7285e0378b4bc1719eb2c06 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 11:22:30 -0400 Subject: [PATCH 473/548] Docblock, more tests --- user/access_cache.go | 17 +++++++--- user/access_cache_test.go | 70 ++++++++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/user/access_cache.go b/user/access_cache.go index 5c5359fd..a49327d2 100644 --- a/user/access_cache.go +++ b/user/access_cache.go @@ -113,9 +113,17 @@ func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found return false, false, false } -// pickBestNoLock returns the highest-priority entry for a single user, combining -// the exact-match O(1) probe with a linear scan over the (usually empty or tiny) -// pattern list. Caller must hold c.mu (RLock is sufficient). +// pickBestNoLock returns the highest-priority entry for a single user. When +// more than one of that user's rules matches the requested topic, the winner +// is chosen by: +// +// 1. longer stored pattern beats shorter (a more specific rule wins over a +// more general one) +// 2. at equal length, write beats read (a stronger permission wins the tie) +// +// Exact and wildcard rules are ranked together under the same criteria, so +// an exact "foo" (length 3) beats a wildcard "f%" (length 2), but a wildcard +// "foo%" (length 4) beats an exact "foo" (length 3). func (c *aclCache) pickBestNoLock(username, topic, escapedTopic string) (*aclEntry, bool) { var best aclEntry var found bool @@ -139,8 +147,7 @@ func (c *aclCache) pickBestNoLock(username, topic, escapedTopic string) (*aclEnt func better(a, b aclEntry) bool { if a.length != b.length { return a.length > b.length - } - if a.write != b.write { + } else if a.write != b.write { return a.write } return false diff --git a/user/access_cache_test.go b/user/access_cache_test.go index 54d8d071..90839872 100644 --- a/user/access_cache_test.go +++ b/user/access_cache_test.go @@ -64,14 +64,6 @@ func TestCompileLikeToRegex_RegexMetaCharsInTopic(t *testing.T) { require.False(t, r.MatchString("foo.bar")) // would match if '-' leaked into a character class } -func TestACLCache_LookupOnNilReceiverSafe(t *testing.T) { - var c *aclCache - read, write, found := c.Lookup("phil", "mytopic") - require.False(t, found) - require.False(t, read) - require.False(t, write) -} - func TestACLCache_LookupBeforeReload(t *testing.T) { // A freshly-constructed cache has empty exact and wildcards maps. The // cache treats this as "no rule found", which the caller resolves via @@ -142,6 +134,36 @@ func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) { require.False(t, write) } +func TestACLCache_SpecificUserBeatsEveryoneEvenWhenShorter(t *testing.T) { + // The SQL's "user_name DESC" sort key takes precedence over LENGTH(topic). + // Concretely: a specific user with a shorter matching rule still wins over + // Everyone with a longer matching rule. + c := newAccessCache() + loadCache(t, c, []rawACLRow{ + {user: Everyone, topic: "foo", read: true, write: true}, // exact, length 3 + {user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all + }) + read, write, found := c.Lookup("phil", "foo") + require.True(t, found) + require.False(t, read) + require.False(t, write) +} + +func TestACLCache_SpecificUserBeatsEveryoneRegardlessOfWrite(t *testing.T) { + // Same-length rules but conflicting permissions across user boundary: the + // specific user always wins, even if its permission set is weaker (or + // stronger, in either direction). + c := newAccessCache() + loadCache(t, c, []rawACLRow{ + {user: Everyone, topic: "mytopic", read: true, write: true}, // wide-open + {user: "phil", topic: "mytopic", read: true, write: false}, // read-only for phil + }) + read, write, found := c.Lookup("phil", "mytopic") + require.True(t, found) + require.True(t, read) + require.False(t, write) +} + func TestACLCache_AnonymousReadsEveryone(t *testing.T) { c := newAccessCache() loadCache(t, c, []rawACLRow{ @@ -167,6 +189,38 @@ func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) { require.True(t, write) } +func TestACLCache_ExactBeatsShorterWildcardSameUser(t *testing.T) { + // Same user, two matching rules: exact "foo" (length 3) and wildcard "f%" + // (length 2). The longer one wins, which is the exact rule -- mirroring + // the SQL's "LENGTH(topic) DESC" tie-break. Crucially, the cache must seed + // "best" from the exact map probe before walking wildcards, otherwise a + // shorter wildcard could overwrite a longer exact. + c := newAccessCache() + loadCache(t, c, []rawACLRow{ + {user: "phil", topic: "foo", read: true, write: true}, // exact, length 3 + {user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all + }) + read, write, found := c.Lookup("phil", "foo") + require.True(t, found) + require.True(t, read) + require.True(t, write) +} + +func TestACLCache_LongerWildcardBeatsExactSameUser(t *testing.T) { + // Same user, two matching rules: exact "foo" (length 3) and wildcard "foo%" + // (length 4). The wildcard wins on length DESC. Exercises the "swap best + // to wildcard when better() returns true" path. + c := newAccessCache() + loadCache(t, c, []rawACLRow{ + {user: "phil", topic: "foo", read: false, write: false}, // exact, length 3, deny-all + {user: "phil", topic: "foo%", read: true, write: true}, // wildcard, length 4 + }) + read, write, found := c.Lookup("phil", "foo") + require.True(t, found) + require.True(t, read) + require.True(t, write) +} + func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) { // Two wildcard rules of identical length for the same user. The write rule // should win the tie-break. The two-rows-with-same-topic shape is From 03d405ed807f37e3cac836cb62b5ff394ac051d3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 11:42:54 -0400 Subject: [PATCH 474/548] Rename acl cahce --- user/access_cache.go | 16 ++++++++-------- user/access_cache_test.go | 18 +++++++++--------- user/manager.go | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/user/access_cache.go b/user/access_cache.go index a49327d2..cfd983b3 100644 --- a/user/access_cache.go +++ b/user/access_cache.go @@ -8,7 +8,7 @@ import ( "heckel.io/ntfy/v2/db" ) -// aclCache is an in-memory index over the entire user_access table. +// accessCache is an in-memory index over the entire user_access table. // // exact[username][escapedTopic] returns the matching entry in O(1) for the common // case where the requested topic appears verbatim in some rule. The key is the @@ -18,7 +18,7 @@ import ( // pattern[username] is the linear-scan list of %-bearing rules for that user. // Walked per request; trivially small in practice. Wildcards are NOT u_everyone- // only -- any user can create them. -type aclCache struct { +type accessCache struct { exact map[string]map[string]aclEntry pattern map[string][]aclEntry mu sync.RWMutex // Protect exact and pattern @@ -35,7 +35,7 @@ type aclCache struct { // // pattern is the pre-compiled regex equivalent of the stored LIKE pattern. // For exact-match entries (no % in the stored value) pattern is nil and the -// entry is reachable only through aclCache.exact[username][topic]. +// entry is reachable only through accessCache.exact[username][topic]. type aclEntry struct { length int // len() of the original stored topic/pattern pattern *regexp.Regexp // nil for exact entries @@ -43,8 +43,8 @@ type aclEntry struct { write bool } -func newAccessCache() *aclCache { - return &aclCache{ +func newAccessCache() *accessCache { + return &accessCache{ exact: make(map[string]map[string]aclEntry), pattern: make(map[string][]aclEntry), } @@ -54,7 +54,7 @@ func newAccessCache() *aclCache { // exact and pattern maps under the write lock. The primary is used (not // ReadOnly) so a reload immediately after an ACL mutation sees the freshly- // written rows without replica lag. -func (c *aclCache) reload(d *db.DB, query string) error { +func (c *accessCache) reload(d *db.DB, query string) error { rows, err := d.Query(query) if err != nil { return err @@ -98,7 +98,7 @@ func (c *aclCache) reload(d *db.DB, query string) error { // 1. specific user beats Everyone // 2. longer pattern beats shorter (more specific wins) // 3. write beats read at equal length (write is "stronger") -func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found bool) { +func (c *accessCache) Lookup(usernameOrEveryone, topic string) (read, write, found bool) { escapedTopic := escapeUnderscore(topic) c.mu.RLock() defer c.mu.RUnlock() @@ -124,7 +124,7 @@ func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found // Exact and wildcard rules are ranked together under the same criteria, so // an exact "foo" (length 3) beats a wildcard "f%" (length 2), but a wildcard // "foo%" (length 4) beats an exact "foo" (length 3). -func (c *aclCache) pickBestNoLock(username, topic, escapedTopic string) (*aclEntry, bool) { +func (c *accessCache) pickBestNoLock(username, topic, escapedTopic string) (*aclEntry, bool) { var best aclEntry var found bool if exact, exists := c.exact[username]; exists { diff --git a/user/access_cache_test.go b/user/access_cache_test.go index 90839872..65c109cb 100644 --- a/user/access_cache_test.go +++ b/user/access_cache_test.go @@ -140,8 +140,8 @@ func TestACLCache_SpecificUserBeatsEveryoneEvenWhenShorter(t *testing.T) { // Everyone with a longer matching rule. c := newAccessCache() loadCache(t, c, []rawACLRow{ - {user: Everyone, topic: "foo", read: true, write: true}, // exact, length 3 - {user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all + {user: Everyone, topic: "foo", read: true, write: true}, // exact, length 3 + {user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all }) read, write, found := c.Lookup("phil", "foo") require.True(t, found) @@ -155,8 +155,8 @@ func TestACLCache_SpecificUserBeatsEveryoneRegardlessOfWrite(t *testing.T) { // stronger, in either direction). c := newAccessCache() loadCache(t, c, []rawACLRow{ - {user: Everyone, topic: "mytopic", read: true, write: true}, // wide-open - {user: "phil", topic: "mytopic", read: true, write: false}, // read-only for phil + {user: Everyone, topic: "mytopic", read: true, write: true}, // wide-open + {user: "phil", topic: "mytopic", read: true, write: false}, // read-only for phil }) read, write, found := c.Lookup("phil", "mytopic") require.True(t, found) @@ -197,8 +197,8 @@ func TestACLCache_ExactBeatsShorterWildcardSameUser(t *testing.T) { // shorter wildcard could overwrite a longer exact. c := newAccessCache() loadCache(t, c, []rawACLRow{ - {user: "phil", topic: "foo", read: true, write: true}, // exact, length 3 - {user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all + {user: "phil", topic: "foo", read: true, write: true}, // exact, length 3 + {user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all }) read, write, found := c.Lookup("phil", "foo") require.True(t, found) @@ -212,8 +212,8 @@ func TestACLCache_LongerWildcardBeatsExactSameUser(t *testing.T) { // to wildcard when better() returns true" path. c := newAccessCache() loadCache(t, c, []rawACLRow{ - {user: "phil", topic: "foo", read: false, write: false}, // exact, length 3, deny-all - {user: "phil", topic: "foo%", read: true, write: true}, // wildcard, length 4 + {user: "phil", topic: "foo", read: false, write: false}, // exact, length 3, deny-all + {user: "phil", topic: "foo%", read: true, write: true}, // wildcard, length 4 }) read, write, found := c.Lookup("phil", "foo") require.True(t, found) @@ -281,7 +281,7 @@ type rawACLRow struct { // loadCache writes the given rows into the cache under its write lock, // preserving the same exact/wildcard partitioning that reload would produce. -func loadCache(t *testing.T, c *aclCache, rows []rawACLRow) { +func loadCache(t *testing.T, c *accessCache, rows []rawACLRow) { t.Helper() exact := make(map[string]map[string]aclEntry) wildcards := make(map[string][]aclEntry) diff --git a/user/manager.go b/user/manager.go index 52217e29..f9995e84 100644 --- a/user/manager.go +++ b/user/manager.go @@ -58,7 +58,7 @@ type Manager struct { queries queries statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) - accessCache *aclCache // In-memory snapshot of user_access; rebuilt after every ACL mutation + accessCache *accessCache // In-memory snapshot of user_access; rebuilt after every ACL mutation quit chan struct{} // Closed by Close() to signal background goroutines to stop mu sync.Mutex } From 301be79f0a752b24550621fecbc367a0bf8f2749 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 14:11:55 -0400 Subject: [PATCH 475/548] Per-user reload --- user/access_cache.go | 87 +++++++++++++++++++++++++++++---- user/manager.go | 102 ++++++++++++++++++++++++--------------- user/manager_postgres.go | 7 +++ user/manager_sqlite.go | 7 +++ user/types.go | 1 + 5 files changed, 156 insertions(+), 48 deletions(-) diff --git a/user/access_cache.go b/user/access_cache.go index cfd983b3..a1518058 100644 --- a/user/access_cache.go +++ b/user/access_cache.go @@ -64,17 +64,15 @@ func (c *accessCache) reload(d *db.DB, query string) error { patterns := make(map[string][]aclEntry) for rows.Next() { var username, topic string - var entry aclEntry - if err := rows.Scan(&username, &topic, &entry.read, &entry.write); err != nil { + var read, write bool + if err := rows.Scan(&username, &topic, &read, &write); err != nil { return err } - entry.length = len(topic) - if strings.Contains(topic, "%") { - re, err := compileLikeToRegex(topic) - if err != nil { - return err - } - entry.pattern = re + entry, isWildcard, err := newACLEntry(topic, read, write) + if err != nil { + return err + } + if isWildcard { patterns[username] = append(patterns[username], entry) } else { if exacts[username] == nil { @@ -93,6 +91,77 @@ func (c *accessCache) reload(d *db.DB, query string) error { return nil } +// reloadUser refreshes just one user's rules from the database and swaps them +// into the cache. Used as a cheaper, owner-cascade-safe alternative to the +// full bulk reload after a mutation that affects a known set of users. The +// caller is expected to invoke reloadUser for every user whose row set may +// have changed -- typically the targeted user plus Everyone, since most +// reservation flows touch both. +// +// The query must return (topic, read, write) for every row whose user_id +// matches the given username. An empty result set is treated as "this user +// has no rules": the user's entries are removed from both maps so the inner +// maps don't grow unbounded under churn. +func (c *accessCache) reloadUser(d *db.DB, query, username string) error { + rows, err := d.Query(query, username) + if err != nil { + return err + } + defer rows.Close() + exact := make(map[string]aclEntry) + var pattern []aclEntry + for rows.Next() { + var topic string + var read, write bool + if err := rows.Scan(&topic, &read, &write); err != nil { + return err + } + entry, isWildcard, err := newACLEntry(topic, read, write) + if err != nil { + return err + } + if isWildcard { + pattern = append(pattern, entry) + } else { + exact[topic] = entry + } + } + if err := rows.Err(); err != nil { + return err + } + c.mu.Lock() + if len(exact) == 0 { + delete(c.exact, username) + } else { + c.exact[username] = exact + } + if len(pattern) == 0 { + delete(c.pattern, username) + } else { + c.pattern[username] = pattern + } + c.mu.Unlock() + return nil +} + +// newACLEntry builds an aclEntry from one user_access row's values. The +// isWildcard return tells the caller which storage slot the entry belongs in: +// the per-user wildcard slice if true, the per-user exact map if false. +// Wildcards have their LIKE pattern pre-compiled into entry.pattern; exact +// entries leave entry.pattern nil. +func newACLEntry(topic string, read, write bool) (entry aclEntry, isWildcard bool, err error) { + entry = aclEntry{length: len(topic), read: read, write: write} + if !strings.Contains(topic, "%") { + return entry, false, nil + } + re, err := compileLikeToRegex(topic) + if err != nil { + return entry, true, err + } + entry.pattern = re + return entry, true, nil +} + // Lookup returns the effective (read, write, found) permission for the given // (username, topic), preserving the priority ordering of the original SQL query: // 1. specific user beats Everyone diff --git a/user/manager.go b/user/manager.go index f9995e84..4626d8e6 100644 --- a/user/manager.go +++ b/user/manager.go @@ -40,9 +40,8 @@ const ( DefaultUserPasswordBcryptCost = 10 // DefaultAccessCacheReloadInterval bounds how stale the in-memory ACL snapshot // can be relative to writes made by *other* processes (e.g. a separate `ntfy - // access` CLI invocation modifying the same database). Mutations performed - // by this Manager refresh the cache synchronously and do not depend on this. - DefaultAccessCacheReloadInterval = 5 * time.Second + // access` CLI invocation modifying the same database) + DefaultAccessCacheReloadInterval = 60 * time.Second ) var ( @@ -87,8 +86,6 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil { return nil, err } - // Populate the ACL cache after provisioning so the initial snapshot includes - // any provisioned access rules. Subsequent mutations call reloadAccessCache. if err := manager.reloadAccessCache(); err != nil { return nil, err } @@ -99,18 +96,22 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { return manager, nil } -// reloadAccessCache rebuilds the in-memory ACL snapshot from the primary -// database. Called once at startup (after provisioning) and after every method -// that mutates user_access (directly or by cascade from "user" deletion). +// reloadAccessCache rebuilds the in-memory access cache from the primary database func (a *Manager) reloadAccessCache() error { return a.accessCache.reload(a.db, a.queries.selectAllAccessForCache) } -// asyncAccessCacheReloader periodically refreshes the ACL snapshot so that -// writes made by other processes against the same database (most notably the -// `ntfy access` CLI subcommand running while a server holds the cache) become -// visible within the configured interval. This Manager's own writes do not -// depend on the poller -- they refresh the cache synchronously. +// reloadAccessCacheUsers refreshes the cache slices for the given usernames +func (a *Manager) reloadAccessCacheUsers(usernames ...string) error { + for _, username := range usernames { + if err := a.accessCache.reloadUser(a.db, a.queries.selectAccessForCacheByUser, username); err != nil { + return err + } + } + return nil +} + +// asyncAccessCacheReloader periodically refreshes the access cache func (a *Manager) asyncAccessCacheReloader(interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -198,13 +199,16 @@ func (a *Manager) RemoveUser(username string) error { if err := a.CanChangeUser(username); err != nil { return err } - if err := db.ExecTx(a.db, func(tx *sql.Tx) error { + err := db.ExecTx(a.db, func(tx *sql.Tx) error { return a.removeUserTx(tx, username) - }); err != nil { + }) + if err != nil { return err } - // user_access rows are cascade-deleted along with the user; refresh the snapshot. - return a.reloadAccessCache() + // user_access rows are cascade-deleted along with the user (both by user_id + // and by owner_user_id). Refresh this user's own slice (now empty) and + // Everyone's slice, since reservations owned by this user landed there too. + return a.reloadAccessCacheUsers(username, Everyone) } // removeUserTx deletes the user with the given username @@ -225,7 +229,7 @@ func (a *Manager) MarkUserRemoved(user *User) error { if !AllowedUsername(user.Name) { return ErrInvalidArgument } - if err := db.ExecTx(a.db, func(tx *sql.Tx) error { + err := db.ExecTx(a.db, func(tx *sql.Tx) error { if err := a.resetUserAccessTx(tx, user.Name); err != nil { return err } @@ -237,11 +241,14 @@ func (a *Manager) MarkUserRemoved(user *User) error { return err } return nil - }); err != nil { + }) + if err != nil { return err } - // resetUserAccessTx wiped this user's user_access rows; refresh the snapshot. - return a.reloadAccessCache() + // resetUserAccessTx deleted this user's rows AND any row owned by this user + // (typically the matching Everyone rows from their reservations). Refresh + // both slices to mirror the DB exactly. + return a.reloadAccessCacheUsers(user.Name, Everyone) } // RemoveDeletedUsers deletes all users that have been marked deleted @@ -281,15 +288,17 @@ func (a *Manager) ChangeRole(username string, role Role) error { if err := a.CanChangeUser(username); err != nil { return err } - if err := db.ExecTx(a.db, func(tx *sql.Tx) error { + err := db.ExecTx(a.db, func(tx *sql.Tx) error { return a.changeRoleTx(tx, username, role) - }); err != nil { + }) + if err != nil { return err } - // Promotion to admin clears user_access rows for the user; refresh the snapshot. - // Other role changes are no-ops for the cache but reloading is cheap and keeps - // the code path uniform. - return a.reloadAccessCache() + // Promotion to admin clears user_access rows for this user AND rows owned + // by this user (Everyone rows from their reservations). Other role changes + // are no-ops for the cache but reloading the two affected slices is cheap + // and keeps the code path uniform. + return a.reloadAccessCacheUsers(username, Everyone) } // changeRoleTx changes a user's role @@ -650,12 +659,14 @@ func (a *Manager) resolvePerms(base, perm Permission) error { // read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry // owner may either be a user (username), or the system (empty). func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { - if err := db.ExecTx(a.db, func(tx *sql.Tx) error { + err := db.ExecTx(a.db, func(tx *sql.Tx) error { return a.allowAccessTx(tx, username, topicPattern, permission, false) - }); err != nil { + }) + if err != nil { return err } - return a.reloadAccessCache() + // Only this user's row set changed; refresh their slice only. + return a.reloadAccessCacheUsers(username) } func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error { @@ -671,12 +682,20 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is // empty) for an entire user. The parameter topicPattern may include wildcards (*). func (a *Manager) ResetAccess(username string, topicPattern string) error { - if err := db.ExecTx(a.db, func(tx *sql.Tx) error { + err := db.ExecTx(a.db, func(tx *sql.Tx) error { return a.resetAccessTx(tx, username, topicPattern) - }); err != nil { + }) + if err != nil { return err } - return a.reloadAccessCache() + // "Delete all access" affects every user; do the bulk reload. + // Otherwise refresh the named user plus Everyone, since resetUserAccessTx + // and deleteTopicAccess both touch rows owned by the user (typically + // Everyone rows from their reservations). + if username == "" { + return a.reloadAccessCache() + } + return a.reloadAccessCacheUsers(username, Everyone) } func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error { @@ -790,7 +809,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { return ErrInvalidArgument } - if err := db.ExecTx(a.db, func(tx *sql.Tx) error { + err := db.ExecTx(a.db, func(tx *sql.Tx) error { if limit > 0 { hasReservation, err := a.hasReservationTx(tx, username, topic) if err != nil { @@ -813,10 +832,12 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss return err } return nil - }); err != nil { + }) + if err != nil { return err } - return a.reloadAccessCache() + // Both user's and Everyone's rows changed. + return a.reloadAccessCacheUsers(username, Everyone) } // RemoveReservations deletes the access control entries associated with the given username/topic, @@ -831,17 +852,20 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { return ErrInvalidArgument } } - if err := db.ExecTx(a.db, func(tx *sql.Tx) error { + err := db.ExecTx(a.db, func(tx *sql.Tx) error { for _, topic := range topics { if err := a.removeReservationAccessTx(tx, username, topic); err != nil { return err } } return nil - }); err != nil { + }) + if err != nil { return err } - return a.reloadAccessCache() + // Mirror the DB: rows for this user and any Everyone rows owned by this + // user are gone. Refresh both slices. + return a.reloadAccessCacheUsers(username, Everyone) } // Reservations returns all user-owned topics, and the associated everyone-access diff --git a/user/manager_postgres.go b/user/manager_postgres.go index d3bb6f5f..8dec4c7d 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -75,6 +75,12 @@ const ( FROM user_access a JOIN "user" u ON u.id = a.user_id ` + postgresSelectAccessForCacheByUserQuery = ` + SELECT a.topic, a.read, a.write + FROM user_access a + JOIN "user" u ON u.id = a.user_id + WHERE u.user_name = $1 + ` postgresSelectUserAllAccessQuery = ` SELECT user_id, topic, read, write, provisioned FROM user_access @@ -243,6 +249,7 @@ var postgresQueries = queries{ deleteUsersMarked: postgresDeleteUsersMarkedQuery, deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery, selectAllAccessForCache: postgresSelectAllAccessForCacheQuery, + selectAccessForCacheByUser: postgresSelectAccessForCacheByUserQuery, selectUserAllAccess: postgresSelectUserAllAccessQuery, selectUserAccess: postgresSelectUserAccessQuery, selectUserReservations: postgresSelectUserReservationsQuery, diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 4d498ce2..26e22b36 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -81,6 +81,12 @@ const ( FROM user_access a JOIN user u ON u.id = a.user_id ` + sqliteSelectAccessForCacheByUserQuery = ` + SELECT a.topic, a.read, a.write + FROM user_access a + JOIN user u ON u.id = a.user_id + WHERE u.user = ? + ` sqliteSelectUserAllAccessQuery = ` SELECT user_id, topic, read, write, provisioned FROM user_access @@ -241,6 +247,7 @@ var sqliteQueries = queries{ deleteUsersMarked: sqliteDeleteUsersMarkedQuery, deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery, selectAllAccessForCache: sqliteSelectAllAccessForCacheQuery, + selectAccessForCacheByUser: sqliteSelectAccessForCacheByUserQuery, selectUserAllAccess: sqliteSelectUserAllAccessQuery, selectUserAccess: sqliteSelectUserAccessQuery, selectUserReservations: sqliteSelectUserReservationsQuery, diff --git a/user/types.go b/user/types.go index 6d2650ed..3af01f5d 100644 --- a/user/types.go +++ b/user/types.go @@ -314,6 +314,7 @@ type queries struct { // Access queries selectAllAccessForCache string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache + selectAccessForCacheByUser string // Per-user load: (topic, read, write) for one username; used to refresh just one user's slice of the cache after mutation selectUserAllAccess string selectUserAccess string selectUserReservations string From 204723f3c09357e4eb8463420d4178d2b6006403 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 14:28:04 -0400 Subject: [PATCH 476/548] Make opt-in flag --- cmd/access_test.go | 7 -- cmd/user.go | 10 +-- cmd/user_test.go | 7 +- server/config.go | 2 + server/server.go | 1 + user/manager.go | 72 +++++++++++++++----- user/manager_postgres.go | 8 +++ user/manager_sqlite.go | 8 +++ user/manager_test.go | 142 +++++++++++++++++++++++++++++++++++++++ user/types.go | 22 +++--- 10 files changed, 236 insertions(+), 43 deletions(-) diff --git a/cmd/access_test.go b/cmd/access_test.go index f280a9e9..8810b6b3 100644 --- a/cmd/access_test.go +++ b/cmd/access_test.go @@ -7,7 +7,6 @@ import ( "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/test" "testing" - "time" ) func TestCLI_Access_Show(t *testing.T) { @@ -44,12 +43,6 @@ user * (role: anonymous, tier: none) ` require.Equal(t, expected, stdout.String()) - // The CLI commands above ran against a separate Manager instance (their own - // process-equivalent), so the server's ACL cache hasn't seen the new grants - // yet. Wait for the server's background reloader (interval set in - // newTestServerWithAuth) to pick them up. - time.Sleep(150 * time.Millisecond) - // See if access permissions match app, _, _, _ = newTestApp() require.Error(t, app.Run([]string{ diff --git a/cmd/user.go b/cmd/user.go index 9bb0c5b0..8eca5ce5 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -378,11 +378,11 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, - // CLI Managers are short-lived; the background ACL cache poller would only - // spam "database is closed" warnings after the subcommand returns. Mutations - // still refresh the local cache synchronously; the running server (if any) - // picks them up via its own poller. - AccessCacheReloadInterval: -1, + // CLI subcommands never serve authorizeTopicAccess and are short-lived, + // so the cache (and its background poller) would be wasted work. Mutations + // hit the DB directly; the running server, if any, picks them up via its + // own poller when the cache is enabled there. + AccessCacheEnabled: false, } if databaseURL != "" { host, dbErr := pg.Open(databaseURL) diff --git a/cmd/user_test.go b/cmd/user_test.go index 5b5bff73..91373694 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "testing" - "time" ) func TestCLI_User_Add(t *testing.T) { @@ -129,10 +128,8 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, conf.File = configFile conf.AuthFile = filepath.Join(t.TempDir(), "user.db") conf.AuthDefault = user.PermissionDenyAll - // Tight interval so cross-process writes from the `ntfy access`/`ntfy user` - // CLI commands (which run via a separate Manager) propagate to the server's - // ACL cache within tens of ms instead of the default 5s. - conf.AuthAccessCacheReloadInterval = 25 * time.Millisecond + // Cache is off by default (matches self-hoster setup), so the server reads + // authorizations directly from the DB and sees CLI mutations immediately. s, port = test.StartServerWithConfig(t, conf) return } diff --git a/server/config.go b/server/config.go index f6f13eca..a1ba4d40 100644 --- a/server/config.go +++ b/server/config.go @@ -116,6 +116,7 @@ type Config struct { AuthTokens map[string][]*user.Token AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration + AuthAccessCacheEnabled bool AuthAccessCacheReloadInterval time.Duration AttachmentCacheDir string AttachmentTotalSizeLimit int64 @@ -224,6 +225,7 @@ func NewConfig() *Config { AuthDefault: user.PermissionReadWrite, AuthBcryptCost: user.DefaultUserPasswordBcryptCost, AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, + AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled, // Opt-in (e.g. ntfy.sh) via server.yml AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval, AttachmentCacheDir: "", AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, diff --git a/server/server.go b/server/server.go index 7bcbbb09..c380bd26 100644 --- a/server/server.go +++ b/server/server.go @@ -257,6 +257,7 @@ func New(conf *Config) (*Server, error) { Tokens: conf.AuthTokens, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, + AccessCacheEnabled: conf.AuthAccessCacheEnabled, AccessCacheReloadInterval: conf.AuthAccessCacheReloadInterval, } if pool != nil { diff --git a/user/manager.go b/user/manager.go index 4626d8e6..292d59b2 100644 --- a/user/manager.go +++ b/user/manager.go @@ -38,9 +38,14 @@ const ( const ( DefaultUserStatsQueueWriterInterval = 33 * time.Second DefaultUserPasswordBcryptCost = 10 + // DefaultAccessCacheEnabled is the default for Config.AccessCacheEnabled. + // Off by default so self-hosters keep the direct-DB authorizeTopicAccess + // path; ntfy.sh opts in via server config. + DefaultAccessCacheEnabled = false // DefaultAccessCacheReloadInterval bounds how stale the in-memory ACL snapshot // can be relative to writes made by *other* processes (e.g. a separate `ntfy - // access` CLI invocation modifying the same database) + // access` CLI invocation modifying the same database). Only honored when the + // cache is enabled. DefaultAccessCacheReloadInterval = 60 * time.Second ) @@ -75,34 +80,46 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval } manager := &Manager{ - config: config, - db: d, - statsQueue: make(map[string]*Stats), - tokenQueue: make(map[string]*TokenUpdate), - accessCache: newAccessCache(), - quit: make(chan struct{}), - queries: queries, + config: config, + db: d, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), + quit: make(chan struct{}), + queries: queries, + } + if config.AccessCacheEnabled { + manager.accessCache = newAccessCache() } if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil { return nil, err } + // Populate the cache after provisioning so the initial snapshot includes + // any provisioned access rules. No-op when the cache is disabled. if err := manager.reloadAccessCache(); err != nil { return nil, err } go manager.asyncQueueWriter(manager.config.QueueWriterInterval) - if manager.config.AccessCacheReloadInterval > 0 { + if manager.accessCache != nil && manager.config.AccessCacheReloadInterval > 0 { go manager.asyncAccessCacheReloader(manager.config.AccessCacheReloadInterval) } return manager, nil } -// reloadAccessCache rebuilds the in-memory access cache from the primary database +// reloadAccessCache rebuilds the in-memory access cache from the primary +// database. No-op when the cache is disabled. func (a *Manager) reloadAccessCache() error { + if a.accessCache == nil { + return nil + } return a.accessCache.reload(a.db, a.queries.selectAllAccessForCache) } -// reloadAccessCacheUsers refreshes the cache slices for the given usernames +// reloadAccessCacheUsers refreshes the cache slices for the given usernames. +// No-op when the cache is disabled. func (a *Manager) reloadAccessCacheUsers(usernames ...string) error { + if a.accessCache == nil { + return nil + } for _, username := range usernames { if err := a.accessCache.reloadUser(a.db, a.queries.selectAccessForCacheByUser, username); err != nil { return err @@ -737,15 +754,34 @@ func (a *Manager) AllowReservation(username string, topic string) error { // authorizeTopicAccess returns the read/write permissions for the given username and topic. // The found return value indicates whether an ACL entry was found at all. // -// - The cache may contain two matching entries (one for everyone, and one for the user), but prioritizes the user. -// - Furthermore, the lookup prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" -// - It also prioritizes write permissions over read permissions +// Priority: +// - specific user beats Everyone +// - longer pattern beats shorter (a more specific rule beats a more general one, +// e.g. "test*" > "*") +// - write beats read at equal length // -// The lookup is served entirely from the in-memory snapshot maintained by accessCache, -// so this is on the hot path of every authenticatable HTTP request and must stay allocation-free. +// When AccessCacheEnabled is true (config), the lookup is served entirely from +// the in-memory snapshot maintained by accessCache. Otherwise the original SQL +// query is executed against the database on every call. func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) { - read, write, found = a.accessCache.Lookup(usernameOrEveryone, topic) - return read, write, found, nil + if a.accessCache != nil { + read, write, found = a.accessCache.Lookup(usernameOrEveryone, topic) + return read, write, found, nil + } + rows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic) + if err != nil { + return false, false, false, err + } + defer rows.Close() + if !rows.Next() { + return false, false, false, nil + } + if err := rows.Scan(&read, &write); err != nil { + return false, false, false, err + } else if err := rows.Err(); err != nil { + return false, false, false, err + } + return read, write, true, nil } // AllGrants returns all user-specific access control entries, mapped to their respective user IDs diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 8dec4c7d..8f0079b6 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -70,6 +70,13 @@ const ( postgresDeleteUsersProvisionedQuery = `DELETE FROM "user" WHERE provisioned = true` // Access queries + postgresSelectTopicPermsQuery = ` + SELECT read, write + FROM user_access a + JOIN "user" u ON u.id = a.user_id + WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\' + ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC + ` postgresSelectAllAccessForCacheQuery = ` SELECT u.user_name, a.topic, a.read, a.write FROM user_access a @@ -248,6 +255,7 @@ var postgresQueries = queries{ deleteUserTier: postgresDeleteUserTierQuery, deleteUsersMarked: postgresDeleteUsersMarkedQuery, deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery, + selectTopicPerms: postgresSelectTopicPermsQuery, selectAllAccessForCache: postgresSelectAllAccessForCacheQuery, selectAccessForCacheByUser: postgresSelectAccessForCacheByUserQuery, selectUserAllAccess: postgresSelectUserAllAccessQuery, diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 26e22b36..a1ca4700 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -76,6 +76,13 @@ const ( sqliteDeleteUsersProvisionedQuery = `DELETE FROM user WHERE provisioned = 1` // Access queries + sqliteSelectTopicPermsQuery = ` + SELECT read, write + FROM user_access a + JOIN user u ON u.id = a.user_id + WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\' + ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC + ` sqliteSelectAllAccessForCacheQuery = ` SELECT u.user, a.topic, a.read, a.write FROM user_access a @@ -246,6 +253,7 @@ var sqliteQueries = queries{ deleteUserTier: sqliteDeleteUserTierQuery, deleteUsersMarked: sqliteDeleteUsersMarkedQuery, deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery, + selectTopicPerms: sqliteSelectTopicPermsQuery, selectAllAccessForCache: sqliteSelectAllAccessForCacheQuery, selectAccessForCacheByUser: sqliteSelectAccessForCacheByUserQuery, selectUserAllAccess: sqliteSelectUserAllAccessQuery, diff --git a/user/manager_test.go b/user/manager_test.go index 3bdb15b2..5d6944f2 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -2169,6 +2169,148 @@ func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) { }) } +// TestAuthorizeTopicAccess_CacheAndDirectDBAgree wires up two Managers on the +// same backend storage -- one with AccessCacheEnabled=true (in-memory cache +// path) and one with AccessCacheEnabled=false (direct SQL path) -- then runs +// an identical battery of authorizeTopicAccess queries against both and +// asserts byte-identical (read, write, found) responses for every query. +// This protects the in-memory implementation from drifting away from the +// SQL behavior it is meant to mirror. +func TestAuthorizeTopicAccess_CacheAndDirectDBAgree(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + // Seed via a Manager with the cache enabled. Writes go to the shared + // backend; both Managers will see them after the writes commit. + writer := newManager(&Config{ + DefaultAccess: PermissionDenyAll, + BcryptCost: bcrypt.MinCost, + AccessCacheEnabled: true, + }) + t.Cleanup(func() { writer.Close() }) + + require.Nil(t, writer.AddUser("phil", "mypass", RoleAdmin, false)) + require.Nil(t, writer.AddUser("ben", "mypass", RoleUser, false)) + require.Nil(t, writer.AddUser("alice", "mypass", RoleUser, false)) + + // A mix that exercises every branch of the priority logic: + // - exact and wildcard rules for the same user + // - exact and wildcard rules under Everyone + // - Everyone rules that are longer than the matching user rule + // - literal underscores (stored as "\_") + // - deny-all permissions + require.Nil(t, writer.AllowAccess("ben", "mytopic", PermissionReadWrite)) + require.Nil(t, writer.AllowAccess("ben", "readme", PermissionRead)) + require.Nil(t, writer.AllowAccess("ben", "writeme", PermissionWrite)) + require.Nil(t, writer.AllowAccess("ben", "ben_topic", PermissionReadWrite)) + require.Nil(t, writer.AllowAccess("ben", "mytopic*", PermissionRead)) + require.Nil(t, writer.AllowAccess("alice", "alice_*", PermissionWrite)) + require.Nil(t, writer.AllowAccess("alice", "secret", PermissionDenyAll)) + require.Nil(t, writer.AllowAccess(Everyone, "announcements", PermissionRead)) + require.Nil(t, writer.AllowAccess(Everyone, "up*", PermissionWrite)) + require.Nil(t, writer.AllowAccess(Everyone, "mytopic", PermissionDenyAll)) + + // Build a reader Manager with the cache OFF, pointing at the same backend. + reader := newManager(&Config{ + DefaultAccess: PermissionDenyAll, + BcryptCost: bcrypt.MinCost, + AccessCacheEnabled: false, + }) + t.Cleanup(func() { reader.Close() }) + + // Probe matrix: every (user, topic) pair that exercises some branch. + cases := []struct { + user, topic string + }{ + // Anonymous reads. + {Everyone, "announcements"}, + {Everyone, "up42"}, + {Everyone, "up"}, + {Everyone, "downstream"}, + {Everyone, "mytopic"}, + {Everyone, "nope"}, + // Specific user, only-user rules. + {"ben", "mytopic"}, + {"ben", "readme"}, + {"ben", "writeme"}, + {"ben", "ben_topic"}, + {"ben", "benXtopic"}, // underscore in rule means "X" must NOT match + // Specific user falls through to Everyone. + {"ben", "announcements"}, + {"ben", "up5"}, + {"alice", "announcements"}, + // Wildcards with literal underscores. + {"alice", "alice_anything"}, + {"alice", "alice_"}, + {"alice", "aliceX"}, // does NOT match alice_* + // Exact-vs-wildcard overlap for the same user (ben has both + // "mytopic" exact and "mytopic*" wildcard). + {"ben", "mytopic"}, // exact wins on length + {"ben", "mytopicX"}, // only wildcard matches + {"ben", "mytopicYZ"}, // only wildcard matches + // Deny-all override. + {"alice", "secret"}, + // No matching rule anywhere. + {"ben", "completely_unmatched"}, + {"alice", "completely_unmatched"}, + {Everyone, "completely_unmatched"}, + } + + // Sanity: the two Managers must agree on every probe. + for _, tc := range cases { + cRead, cWrite, cFound, cErr := writer.authorizeTopicAccess(tc.user, tc.topic) + dRead, dWrite, dFound, dErr := reader.authorizeTopicAccess(tc.user, tc.topic) + require.Nil(t, cErr, "cache path errored for (%s, %s)", tc.user, tc.topic) + require.Nil(t, dErr, "direct-DB path errored for (%s, %s)", tc.user, tc.topic) + require.Equal(t, dFound, cFound, "found mismatch for (%s, %s)", tc.user, tc.topic) + require.Equal(t, dRead, cRead, "read mismatch for (%s, %s)", tc.user, tc.topic) + require.Equal(t, dWrite, cWrite, "write mismatch for (%s, %s)", tc.user, tc.topic) + } + }) +} + +// TestAccessCacheReloadInterval_PicksUpExternalWrite proves that the +// background reloader actually closes the cross-process coherence gap: a +// write made through a *different* Manager on the same backend becomes +// visible to a cache-enabled Manager within roughly one reload interval, +// without that Manager being told about the write. +func TestAccessCacheReloadInterval_PicksUpExternalWrite(t *testing.T) { + const interval = 25 * time.Millisecond + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + // reader holds the cache and polls; writer plays the role of an + // out-of-band process (e.g. `ntfy access` CLI) writing to the same + // backend. + reader := newManager(&Config{ + DefaultAccess: PermissionDenyAll, + BcryptCost: bcrypt.MinCost, + AccessCacheEnabled: true, + AccessCacheReloadInterval: interval, + }) + t.Cleanup(func() { reader.Close() }) + + writer := newManager(&Config{ + DefaultAccess: PermissionDenyAll, + BcryptCost: bcrypt.MinCost, + AccessCacheEnabled: false, + }) + t.Cleanup(func() { writer.Close() }) + + require.Nil(t, writer.AddUser("phil", "mypass", RoleUser, false)) + // Sanity: before the write, the reader sees no rule for this topic. + _, _, found, err := reader.authorizeTopicAccess("phil", "via-poller") + require.Nil(t, err) + require.False(t, found) + + // Write through the second Manager. reader's cache is unaware. + require.Nil(t, writer.AllowAccess("phil", "via-poller", PermissionReadWrite)) + + // Wait for the poller to catch up. The interval is 25ms; allow a + // generous multiple to keep this test from flaking on slow CI. + require.Eventually(t, func() bool { + read, write, found, err := reader.authorizeTopicAccess("phil", "via-poller") + return err == nil && found && read && write + }, 2*time.Second, 10*time.Millisecond, "reader's cache never observed the external write") + }) +} + func TestStoreReservations(t *testing.T) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) { require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) diff --git a/user/types.go b/user/types.go index 3af01f5d..65032aa3 100644 --- a/user/types.go +++ b/user/types.go @@ -256,14 +256,19 @@ type Config struct { QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database BcryptCost int // Cost of generated passwords; lowering makes testing faster - // AccessCacheReloadInterval bounds the staleness of the in-memory ACL cache - // relative to writes from other processes (e.g. `ntfy access` CLI against a - // running server). - // 0 -> use DefaultAccessCacheReloadInterval - // negative -> disable the background poller; cache only refreshes on this - // Manager's own ACL mutations. Use this for short-lived Managers - // (e.g. the CLI subcommands) where polling is wasted work. - // positive -> poll at the given interval + // AccessCacheEnabled gates the in-memory ACL cache. When false (the + // default), authorizeTopicAccess runs the direct SQL query against the + // database on every call -- mutations and authorization are unaffected by + // any cache logic. When true, the Manager keeps an in-memory snapshot of + // user_access and serves authorizeTopicAccess from it; mutations refresh + // the affected slices, and a background poller picks up cross-process + // writes at AccessCacheReloadInterval. + AccessCacheEnabled bool + + // AccessCacheReloadInterval bounds the staleness of the in-memory ACL + // cache relative to writes from other processes (e.g. `ntfy access` CLI + // against a running server). Only honored when AccessCacheEnabled is true. + // Zero falls back to DefaultAccessCacheReloadInterval. AccessCacheReloadInterval time.Duration } @@ -313,6 +318,7 @@ type queries struct { deleteUsersProvisioned string // Access queries + selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled selectAllAccessForCache string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache selectAccessForCacheByUser string // Per-user load: (topic, read, write) for one username; used to refresh just one user's slice of the cache after mutation selectUserAllAccess string From 2f4afbdae5690b16f4c539be820212dc4d138281 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 15:26:28 -0400 Subject: [PATCH 477/548] Manual refinements --- cmd/user.go | 6 +- user/access_cache.go | 213 ++++++++++++++++----------------------- user/manager.go | 57 +++++------ user/manager_postgres.go | 30 ++++-- user/manager_sqlite.go | 23 +++-- user/types.go | 6 +- 6 files changed, 149 insertions(+), 186 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index 8eca5ce5..2e5af5f4 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -378,11 +378,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, - // CLI subcommands never serve authorizeTopicAccess and are short-lived, - // so the cache (and its background poller) would be wasted work. Mutations - // hit the DB directly; the running server, if any, picks them up via its - // own poller when the cache is enabled there. - AccessCacheEnabled: false, + AccessCacheEnabled: false, // Do not cache for CLI commands } if databaseURL != "" { host, dbErr := pg.Open(databaseURL) diff --git a/user/access_cache.go b/user/access_cache.go index a1518058..1318b01d 100644 --- a/user/access_cache.go +++ b/user/access_cache.go @@ -24,21 +24,14 @@ type accessCache struct { mu sync.RWMutex // Protect exact and pattern } -// aclEntry mirrors one user_access row in the in-memory cache. -// -// length is the length of the original stored value (topic for exact rows, -// SQL LIKE pattern for wildcard rows). It is only used by better() to -// implement the "longer pattern beats shorter" tie-break from the original -// SQL ORDER BY. The string itself is intentionally not stored on the entry: -// the exact map already keys on it, and surfacing it would invite misuse -// (wildcard "topics" are actually SQL patterns like "up%"). -// -// pattern is the pre-compiled regex equivalent of the stored LIKE pattern. -// For exact-match entries (no % in the stored value) pattern is nil and the -// entry is reachable only through accessCache.exact[username][topic]. +// aclEntry mirrors one user_access row. length feeds better()'s "longer +// pattern wins" tie-break; the stored topic/pattern string itself is not kept +// on the entry (the exact map already keys on it; surfacing wildcard "topics" +// like "up%" alongside real ones would invite misuse). pattern is the +// compiled regex form of the LIKE pattern; nil for exact entries. type aclEntry struct { - length int // len() of the original stored topic/pattern - pattern *regexp.Regexp // nil for exact entries + length int + pattern *regexp.Regexp read bool write bool } @@ -50,118 +43,6 @@ func newAccessCache() *accessCache { } } -// reload runs the bulk-load query against the primary and swaps in freshly-built -// exact and pattern maps under the write lock. The primary is used (not -// ReadOnly) so a reload immediately after an ACL mutation sees the freshly- -// written rows without replica lag. -func (c *accessCache) reload(d *db.DB, query string) error { - rows, err := d.Query(query) - if err != nil { - return err - } - defer rows.Close() - exacts := make(map[string]map[string]aclEntry) - patterns := make(map[string][]aclEntry) - for rows.Next() { - var username, topic string - var read, write bool - if err := rows.Scan(&username, &topic, &read, &write); err != nil { - return err - } - entry, isWildcard, err := newACLEntry(topic, read, write) - if err != nil { - return err - } - if isWildcard { - patterns[username] = append(patterns[username], entry) - } else { - if exacts[username] == nil { - exacts[username] = make(map[string]aclEntry) - } - exacts[username][topic] = entry - } - } - if err := rows.Err(); err != nil { - return err - } - c.mu.Lock() - c.exact = exacts - c.pattern = patterns - c.mu.Unlock() - return nil -} - -// reloadUser refreshes just one user's rules from the database and swaps them -// into the cache. Used as a cheaper, owner-cascade-safe alternative to the -// full bulk reload after a mutation that affects a known set of users. The -// caller is expected to invoke reloadUser for every user whose row set may -// have changed -- typically the targeted user plus Everyone, since most -// reservation flows touch both. -// -// The query must return (topic, read, write) for every row whose user_id -// matches the given username. An empty result set is treated as "this user -// has no rules": the user's entries are removed from both maps so the inner -// maps don't grow unbounded under churn. -func (c *accessCache) reloadUser(d *db.DB, query, username string) error { - rows, err := d.Query(query, username) - if err != nil { - return err - } - defer rows.Close() - exact := make(map[string]aclEntry) - var pattern []aclEntry - for rows.Next() { - var topic string - var read, write bool - if err := rows.Scan(&topic, &read, &write); err != nil { - return err - } - entry, isWildcard, err := newACLEntry(topic, read, write) - if err != nil { - return err - } - if isWildcard { - pattern = append(pattern, entry) - } else { - exact[topic] = entry - } - } - if err := rows.Err(); err != nil { - return err - } - c.mu.Lock() - if len(exact) == 0 { - delete(c.exact, username) - } else { - c.exact[username] = exact - } - if len(pattern) == 0 { - delete(c.pattern, username) - } else { - c.pattern[username] = pattern - } - c.mu.Unlock() - return nil -} - -// newACLEntry builds an aclEntry from one user_access row's values. The -// isWildcard return tells the caller which storage slot the entry belongs in: -// the per-user wildcard slice if true, the per-user exact map if false. -// Wildcards have their LIKE pattern pre-compiled into entry.pattern; exact -// entries leave entry.pattern nil. -func newACLEntry(topic string, read, write bool) (entry aclEntry, isWildcard bool, err error) { - entry = aclEntry{length: len(topic), read: read, write: write} - if !strings.Contains(topic, "%") { - return entry, false, nil - } - re, err := compileLikeToRegex(topic) - if err != nil { - return entry, true, err - } - entry.pattern = re - return entry, true, nil -} - // Lookup returns the effective (read, write, found) permission for the given // (username, topic), preserving the priority ordering of the original SQL query: // 1. specific user beats Everyone @@ -182,6 +63,68 @@ func (c *accessCache) Lookup(usernameOrEveryone, topic string) (read, write, fou return false, false, false } +// reload scans (user_name, topic, read, write) rows and merges them into the +// cache. With no usernames the cache is replaced wholesale; otherwise the +// query is invoked with those usernames as positional args and only the +// listed users' slices are touched (a username absent from the result drops +// them from both maps). Runs against the primary so a reload after a +// mutation sees the just-written rows. +func (c *accessCache) reload(d *db.DB, query string, usernames ...string) error { + args := make([]any, len(usernames)) + for i, u := range usernames { + args[i] = u + } + rows, err := d.Query(query, args...) + if err != nil { + return err + } + defer rows.Close() + exacts := make(map[string]map[string]aclEntry) + patterns := make(map[string][]aclEntry) + for rows.Next() { + var u, topic string + var read, write bool + if err := rows.Scan(&u, &topic, &read, &write); err != nil { + return err + } + entry, isPattern, err := toACLEntry(topic, read, write) + if err != nil { + return err + } + if isPattern { + patterns[u] = append(patterns[u], entry) + } else { + if exacts[u] == nil { + exacts[u] = make(map[string]aclEntry) + } + exacts[u][topic] = entry + } + } + if err := rows.Err(); err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + if len(usernames) == 0 { + c.exact = exacts + c.pattern = patterns + return nil + } + for _, u := range usernames { + if e, ok := exacts[u]; ok { + c.exact[u] = e + } else { + delete(c.exact, u) + } + if p, ok := patterns[u]; ok { + c.pattern[u] = p + } else { + delete(c.pattern, u) + } + } + return nil +} + // pickBestNoLock returns the highest-priority entry for a single user. When // more than one of that user's rules matches the requested topic, the winner // is chosen by: @@ -211,6 +154,24 @@ func (c *accessCache) pickBestNoLock(username, topic, escapedTopic string) (*acl return &best, found } +// toACLEntry builds an aclEntry from one user_access row's values. The +// isWildcard return tells the caller which storage slot the entry belongs in: +// the per-user wildcard slice if true, the per-user exact map if false. +// Wildcards have their LIKE pattern pre-compiled into entry.pattern; exact +// entries leave entry.pattern nil. +func toACLEntry(topic string, read, write bool) (entry aclEntry, isWildcard bool, err error) { + entry = aclEntry{length: len(topic), read: read, write: write} + if !strings.Contains(topic, "%") { + return entry, false, nil + } + pattern, err := compileLikeToRegex(topic) + if err != nil { + return entry, true, err + } + entry.pattern = pattern + return entry, true, nil +} + // better implements the (length DESC, write DESC) tie-break used by the original // query's ORDER BY for entries owned by the same user. func better(a, b aclEntry) bool { diff --git a/user/manager.go b/user/manager.go index 292d59b2..587ba8f2 100644 --- a/user/manager.go +++ b/user/manager.go @@ -93,39 +93,28 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil { return nil, err } - // Populate the cache after provisioning so the initial snapshot includes - // any provisioned access rules. No-op when the cache is disabled. - if err := manager.reloadAccessCache(); err != nil { - return nil, err - } - go manager.asyncQueueWriter(manager.config.QueueWriterInterval) - if manager.accessCache != nil && manager.config.AccessCacheReloadInterval > 0 { + if manager.accessCache != nil { + if err := manager.maybeReloadAccessCache(); err != nil { + return nil, err + } go manager.asyncAccessCacheReloader(manager.config.AccessCacheReloadInterval) } + go manager.asyncQueueWriter(manager.config.QueueWriterInterval) return manager, nil } -// reloadAccessCache rebuilds the in-memory access cache from the primary -// database. No-op when the cache is disabled. -func (a *Manager) reloadAccessCache() error { +// maybeReloadAccessCache refreshes the in-memory access cache from the +// primary database. No-op when the cache is disabled. With no usernames it +// does a full bulk reload; with one or more it refreshes only those users' +// slices in a single DB round-trip via an IN clause. +func (a *Manager) maybeReloadAccessCache(usernames ...string) error { if a.accessCache == nil { return nil } - return a.accessCache.reload(a.db, a.queries.selectAllAccessForCache) -} - -// reloadAccessCacheUsers refreshes the cache slices for the given usernames. -// No-op when the cache is disabled. -func (a *Manager) reloadAccessCacheUsers(usernames ...string) error { - if a.accessCache == nil { - return nil + if len(usernames) == 0 { + return a.accessCache.reload(a.db, a.queries.selectAccessCacheAll) } - for _, username := range usernames { - if err := a.accessCache.reloadUser(a.db, a.queries.selectAccessForCacheByUser, username); err != nil { - return err - } - } - return nil + return a.accessCache.reload(a.db, a.queries.selectAccessCacheUsersFn(len(usernames)), usernames...) } // asyncAccessCacheReloader periodically refreshes the access cache @@ -137,7 +126,7 @@ func (a *Manager) asyncAccessCacheReloader(interval time.Duration) { case <-a.quit: return case <-ticker.C: - if err := a.reloadAccessCache(); err != nil { + if err := a.maybeReloadAccessCache(); err != nil { log.Tag(tag).Err(err).Warn("Reloading ACL cache failed") } } @@ -225,7 +214,7 @@ func (a *Manager) RemoveUser(username string) error { // user_access rows are cascade-deleted along with the user (both by user_id // and by owner_user_id). Refresh this user's own slice (now empty) and // Everyone's slice, since reservations owned by this user landed there too. - return a.reloadAccessCacheUsers(username, Everyone) + return a.maybeReloadAccessCache(username, Everyone) } // removeUserTx deletes the user with the given username @@ -265,7 +254,7 @@ func (a *Manager) MarkUserRemoved(user *User) error { // resetUserAccessTx deleted this user's rows AND any row owned by this user // (typically the matching Everyone rows from their reservations). Refresh // both slices to mirror the DB exactly. - return a.reloadAccessCacheUsers(user.Name, Everyone) + return a.maybeReloadAccessCache(user.Name, Everyone) } // RemoveDeletedUsers deletes all users that have been marked deleted @@ -274,7 +263,7 @@ func (a *Manager) RemoveDeletedUsers() error { return err } // user_access rows are cascade-deleted with the users; refresh the snapshot. - return a.reloadAccessCache() + return a.maybeReloadAccessCache() } // ChangePassword changes a user's password @@ -315,7 +304,7 @@ func (a *Manager) ChangeRole(username string, role Role) error { // by this user (Everyone rows from their reservations). Other role changes // are no-ops for the cache but reloading the two affected slices is cheap // and keeps the code path uniform. - return a.reloadAccessCacheUsers(username, Everyone) + return a.maybeReloadAccessCache(username, Everyone) } // changeRoleTx changes a user's role @@ -683,7 +672,7 @@ func (a *Manager) AllowAccess(username string, topicPattern string, permission P return err } // Only this user's row set changed; refresh their slice only. - return a.reloadAccessCacheUsers(username) + return a.maybeReloadAccessCache(username) } func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error { @@ -710,9 +699,9 @@ func (a *Manager) ResetAccess(username string, topicPattern string) error { // and deleteTopicAccess both touch rows owned by the user (typically // Everyone rows from their reservations). if username == "" { - return a.reloadAccessCache() + return a.maybeReloadAccessCache() } - return a.reloadAccessCacheUsers(username, Everyone) + return a.maybeReloadAccessCache(username, Everyone) } func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error { @@ -873,7 +862,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss return err } // Both user's and Everyone's rows changed. - return a.reloadAccessCacheUsers(username, Everyone) + return a.maybeReloadAccessCache(username, Everyone) } // RemoveReservations deletes the access control entries associated with the given username/topic, @@ -901,7 +890,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { } // Mirror the DB: rows for this user and any Everyone rows owned by this // user are gone. Refresh both slices. - return a.reloadAccessCacheUsers(username, Everyone) + return a.maybeReloadAccessCache(username, Everyone) } // Reservations returns all user-owned topics, and the associated everyone-access diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 8f0079b6..49d294b3 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -1,6 +1,9 @@ package user import ( + "fmt" + "strings" + "heckel.io/ntfy/v2/db" ) @@ -77,17 +80,11 @@ const ( WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\' ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC ` - postgresSelectAllAccessForCacheQuery = ` + postgresSelectAccessCacheAllQuery = ` SELECT u.user_name, a.topic, a.read, a.write FROM user_access a JOIN "user" u ON u.id = a.user_id ` - postgresSelectAccessForCacheByUserQuery = ` - SELECT a.topic, a.read, a.write - FROM user_access a - JOIN "user" u ON u.id = a.user_id - WHERE u.user_name = $1 - ` postgresSelectUserAllAccessQuery = ` SELECT user_id, topic, read, write, provisioned FROM user_access @@ -232,6 +229,21 @@ const ( ` ) +// postgresSelectAccessCacheUsersQuery builds the per-users cache-load query +// with a "$1, $2, ..." IN clause sized for n usernames. +func postgresSelectAccessCacheUsersQuery(n int) string { + var sb strings.Builder + sb.WriteString(`SELECT u.user_name, a.topic, a.read, a.write FROM user_access a JOIN "user" u ON u.id = a.user_id WHERE u.user_name IN (`) + for i := 0; i < n; i++ { + if i > 0 { + sb.WriteString(",") + } + fmt.Fprintf(&sb, "$%d", i+1) + } + sb.WriteString(")") + return sb.String() +} + // NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool. var postgresQueries = queries{ selectUserByID: postgresSelectUserByIDQuery, @@ -256,8 +268,8 @@ var postgresQueries = queries{ deleteUsersMarked: postgresDeleteUsersMarkedQuery, deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery, selectTopicPerms: postgresSelectTopicPermsQuery, - selectAllAccessForCache: postgresSelectAllAccessForCacheQuery, - selectAccessForCacheByUser: postgresSelectAccessForCacheByUserQuery, + selectAccessCacheAll: postgresSelectAccessCacheAllQuery, + selectAccessCacheUsersFn: postgresSelectAccessCacheUsersQuery, selectUserAllAccess: postgresSelectUserAllAccessQuery, selectUserAccess: postgresSelectUserAccessQuery, selectUserReservations: postgresSelectUserReservationsQuery, diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index a1ca4700..7c6507f2 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "path/filepath" + "strings" _ "github.com/mattn/go-sqlite3" // SQLite driver @@ -83,17 +84,11 @@ const ( WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\' ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC ` - sqliteSelectAllAccessForCacheQuery = ` + sqliteSelectAccessCacheAllQuery = ` SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id ` - sqliteSelectAccessForCacheByUserQuery = ` - SELECT a.topic, a.read, a.write - FROM user_access a - JOIN user u ON u.id = a.user_id - WHERE u.user = ? - ` sqliteSelectUserAllAccessQuery = ` SELECT user_id, topic, read, write, provisioned FROM user_access @@ -231,6 +226,16 @@ const ( ` ) +// sqliteSelectAccessCacheUsersQuery builds the per-users cache-load query +// with a "?, ?, ..." IN clause sized for n usernames. +func sqliteSelectAccessCacheUsersQuery(n int) string { + placeholders := strings.Repeat(",?", n) + if n > 0 { + placeholders = placeholders[1:] // drop the leading comma + } + return `SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id WHERE u.user IN (` + placeholders + `)` +} + var sqliteQueries = queries{ selectUserByID: sqliteSelectUserByIDQuery, selectUserByName: sqliteSelectUserByNameQuery, @@ -254,8 +259,8 @@ var sqliteQueries = queries{ deleteUsersMarked: sqliteDeleteUsersMarkedQuery, deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery, selectTopicPerms: sqliteSelectTopicPermsQuery, - selectAllAccessForCache: sqliteSelectAllAccessForCacheQuery, - selectAccessForCacheByUser: sqliteSelectAccessForCacheByUserQuery, + selectAccessCacheAll: sqliteSelectAccessCacheAllQuery, + selectAccessCacheUsersFn: sqliteSelectAccessCacheUsersQuery, selectUserAllAccess: sqliteSelectUserAllAccessQuery, selectUserAccess: sqliteSelectUserAccessQuery, selectUserReservations: sqliteSelectUserReservationsQuery, diff --git a/user/types.go b/user/types.go index 65032aa3..d2746c77 100644 --- a/user/types.go +++ b/user/types.go @@ -318,9 +318,9 @@ type queries struct { deleteUsersProvisioned string // Access queries - selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled - selectAllAccessForCache string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache - selectAccessForCacheByUser string // Per-user load: (topic, read, write) for one username; used to refresh just one user's slice of the cache after mutation + selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled + selectAccessCacheAll string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache + selectAccessCacheUsersFn func(n int) string // Returns a per-users load query whose IN clause is sized for n usernames selectUserAllAccess string selectUserAccess string selectUserReservations string From 0e2c459d6b601d5b50f6d801d58f966bff257200 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 15:42:12 -0400 Subject: [PATCH 478/548] Further review --- user/manager.go | 42 ++++++++++++++++++++++++---------------- user/manager_postgres.go | 2 +- user/manager_sqlite.go | 2 +- user/types.go | 2 +- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/user/manager.go b/user/manager.go index 587ba8f2..58fd3b2d 100644 --- a/user/manager.go +++ b/user/manager.go @@ -62,7 +62,7 @@ type Manager struct { queries queries statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) - accessCache *accessCache // In-memory snapshot of user_access; rebuilt after every ACL mutation + accessCache *accessCache // In-memory snapshot of user_access; refreshed by maybeReloadAccessCache after every ACL mutation quit chan struct{} // Closed by Close() to signal background goroutines to stop mu sync.Mutex } @@ -76,7 +76,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { if config.QueueWriterInterval.Seconds() <= 0 { config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } - if config.AccessCacheReloadInterval == 0 { + if config.AccessCacheReloadInterval <= 0 { config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval } manager := &Manager{ @@ -87,13 +87,11 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { quit: make(chan struct{}), queries: queries, } - if config.AccessCacheEnabled { - manager.accessCache = newAccessCache() - } if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil { return nil, err } - if manager.accessCache != nil { + if config.AccessCacheEnabled { + manager.accessCache = newAccessCache() if err := manager.maybeReloadAccessCache(); err != nil { return nil, err } @@ -114,10 +112,14 @@ func (a *Manager) maybeReloadAccessCache(usernames ...string) error { if len(usernames) == 0 { return a.accessCache.reload(a.db, a.queries.selectAccessCacheAll) } - return a.accessCache.reload(a.db, a.queries.selectAccessCacheUsersFn(len(usernames)), usernames...) + return a.accessCache.reload(a.db, a.queries.selectAccessCacheUsers(len(usernames)), usernames...) } -// asyncAccessCacheReloader periodically refreshes the access cache +// asyncAccessCacheReloader periodically bulk-reloads the access cache so that +// writes made by other processes against the same database (most notably the +// `ntfy access` CLI subcommand running while a server holds the cache) become +// visible within the configured interval. This Manager's own mutations do +// not depend on the poller -- they refresh affected users synchronously. func (a *Manager) asyncAccessCacheReloader(interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -430,12 +432,18 @@ func (a *Manager) EnqueueUserStats(userID string, stats *Stats) { func (a *Manager) asyncQueueWriter(interval time.Duration) { ticker := time.NewTicker(interval) - for range ticker.C { - if err := a.writeUserStatsQueue(); err != nil { - log.Tag(tag).Err(err).Warn("Writing user stats queue failed") - } - if err := a.writeTokenUpdateQueue(); err != nil { - log.Tag(tag).Err(err).Warn("Writing token update queue failed") + defer ticker.Stop() + for { + select { + case <-a.quit: + return + case <-ticker.C: + if err := a.writeUserStatsQueue(); err != nil { + log.Tag(tag).Err(err).Warn("Writing user stats queue failed") + } + if err := a.writeTokenUpdateQueue(); err != nil { + log.Tag(tag).Err(err).Warn("Writing token update queue failed") + } } } } @@ -694,10 +702,10 @@ func (a *Manager) ResetAccess(username string, topicPattern string) error { if err != nil { return err } - // "Delete all access" affects every user; do the bulk reload. + // Empty username -> deleteAllAccess affected every user, bulk reload. // Otherwise refresh the named user plus Everyone, since resetUserAccessTx - // and deleteTopicAccess both touch rows owned by the user (typically - // Everyone rows from their reservations). + // and deleteTopicAccess both touch rows owned by the user (typically the + // Everyone row from their reservations). if username == "" { return a.maybeReloadAccessCache() } diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 49d294b3..0395baae 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -269,7 +269,7 @@ var postgresQueries = queries{ deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery, selectTopicPerms: postgresSelectTopicPermsQuery, selectAccessCacheAll: postgresSelectAccessCacheAllQuery, - selectAccessCacheUsersFn: postgresSelectAccessCacheUsersQuery, + selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery, selectUserAllAccess: postgresSelectUserAllAccessQuery, selectUserAccess: postgresSelectUserAccessQuery, selectUserReservations: postgresSelectUserReservationsQuery, diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 7c6507f2..ebe81d8d 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -260,7 +260,7 @@ var sqliteQueries = queries{ deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery, selectTopicPerms: sqliteSelectTopicPermsQuery, selectAccessCacheAll: sqliteSelectAccessCacheAllQuery, - selectAccessCacheUsersFn: sqliteSelectAccessCacheUsersQuery, + selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery, selectUserAllAccess: sqliteSelectUserAllAccessQuery, selectUserAccess: sqliteSelectUserAccessQuery, selectUserReservations: sqliteSelectUserReservationsQuery, diff --git a/user/types.go b/user/types.go index d2746c77..93aaa4d2 100644 --- a/user/types.go +++ b/user/types.go @@ -320,7 +320,7 @@ type queries struct { // Access queries selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled selectAccessCacheAll string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache - selectAccessCacheUsersFn func(n int) string // Returns a per-users load query whose IN clause is sized for n usernames + selectAccessCacheUsers func(n int) string // Returns a per-users load query whose IN clause is sized for n usernames selectUserAllAccess string selectUserAccess string selectUserReservations string From a2dc290f31a4c969e02ca6b348e33fd96ef590c9 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 21:25:53 -0400 Subject: [PATCH 479/548] Review --- server/config.go | 6 +++--- user/access_cache_test.go | 12 ++---------- user/manager.go | 31 ++++++++++--------------------- user/types.go | 37 ++++++++++++------------------------- 4 files changed, 27 insertions(+), 59 deletions(-) diff --git a/server/config.go b/server/config.go index a1ba4d40..b7dadddf 100644 --- a/server/config.go +++ b/server/config.go @@ -116,8 +116,8 @@ type Config struct { AuthTokens map[string][]*user.Token AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration - AuthAccessCacheEnabled bool - AuthAccessCacheReloadInterval time.Duration + AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only) + AuthAccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI AttachmentCacheDir string AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 @@ -225,7 +225,7 @@ func NewConfig() *Config { AuthDefault: user.PermissionReadWrite, AuthBcryptCost: user.DefaultUserPasswordBcryptCost, AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, - AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled, // Opt-in (e.g. ntfy.sh) via server.yml + AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled, AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval, AttachmentCacheDir: "", AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, diff --git a/user/access_cache_test.go b/user/access_cache_test.go index 65c109cb..17fd2c2c 100644 --- a/user/access_cache_test.go +++ b/user/access_cache_test.go @@ -2,6 +2,7 @@ package user import ( "regexp" + "strings" "sync" "sync/atomic" "testing" @@ -287,7 +288,7 @@ func loadCache(t *testing.T, c *accessCache, rows []rawACLRow) { wildcards := make(map[string][]aclEntry) for _, r := range rows { e := aclEntry{length: len(r.topic), read: r.read, write: r.write} - if containsPercent(r.topic) { + if strings.Contains(r.topic, "%") { e.pattern = mustCompileLikeToRegex(t, r.topic) wildcards[r.user] = append(wildcards[r.user], e) } else { @@ -309,12 +310,3 @@ func mustCompileLikeToRegex(t *testing.T, pattern string) *regexp.Regexp { require.NoError(t, err) return r } - -func containsPercent(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] == '%' { - return true - } - } - return false -} diff --git a/user/manager.go b/user/manager.go index 58fd3b2d..6cc7e8b7 100644 --- a/user/manager.go +++ b/user/manager.go @@ -38,15 +38,8 @@ const ( const ( DefaultUserStatsQueueWriterInterval = 33 * time.Second DefaultUserPasswordBcryptCost = 10 - // DefaultAccessCacheEnabled is the default for Config.AccessCacheEnabled. - // Off by default so self-hosters keep the direct-DB authorizeTopicAccess - // path; ntfy.sh opts in via server config. - DefaultAccessCacheEnabled = false - // DefaultAccessCacheReloadInterval bounds how stale the in-memory ACL snapshot - // can be relative to writes made by *other* processes (e.g. a separate `ntfy - // access` CLI invocation modifying the same database). Only honored when the - // cache is enabled. - DefaultAccessCacheReloadInterval = 60 * time.Second + DefaultAccessCacheEnabled = false + DefaultAccessCacheReloadInterval = 60 * time.Second ) var ( @@ -95,9 +88,9 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { if err := manager.maybeReloadAccessCache(); err != nil { return nil, err } - go manager.asyncAccessCacheReloader(manager.config.AccessCacheReloadInterval) + go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval) } - go manager.asyncQueueWriter(manager.config.QueueWriterInterval) + go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval) return manager, nil } @@ -115,12 +108,12 @@ func (a *Manager) maybeReloadAccessCache(usernames ...string) error { return a.accessCache.reload(a.db, a.queries.selectAccessCacheUsers(len(usernames)), usernames...) } -// asyncAccessCacheReloader periodically bulk-reloads the access cache so that +// asyncAccessCacheReloadLoop periodically bulk-reloads the access cache so that // writes made by other processes against the same database (most notably the // `ntfy access` CLI subcommand running while a server holds the cache) become // visible within the configured interval. This Manager's own mutations do // not depend on the poller -- they refresh affected users synchronously. -func (a *Manager) asyncAccessCacheReloader(interval time.Duration) { +func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { @@ -213,9 +206,7 @@ func (a *Manager) RemoveUser(username string) error { if err != nil { return err } - // user_access rows are cascade-deleted along with the user (both by user_id - // and by owner_user_id). Refresh this user's own slice (now empty) and - // Everyone's slice, since reservations owned by this user landed there too. + // Reload user-specific parts of the access cache return a.maybeReloadAccessCache(username, Everyone) } @@ -253,9 +244,7 @@ func (a *Manager) MarkUserRemoved(user *User) error { if err != nil { return err } - // resetUserAccessTx deleted this user's rows AND any row owned by this user - // (typically the matching Everyone rows from their reservations). Refresh - // both slices to mirror the DB exactly. + // Reload user-specific parts of the access cache return a.maybeReloadAccessCache(user.Name, Everyone) } @@ -264,7 +253,7 @@ func (a *Manager) RemoveDeletedUsers() error { if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil { return err } - // user_access rows are cascade-deleted with the users; refresh the snapshot. + // Full cache reload, because we don't know what the query affects return a.maybeReloadAccessCache() } @@ -430,7 +419,7 @@ func (a *Manager) EnqueueUserStats(userID string, stats *Stats) { a.statsQueue[userID] = stats } -func (a *Manager) asyncQueueWriter(interval time.Duration) { +func (a *Manager) asyncQueueWriteLoop(interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { diff --git a/user/types.go b/user/types.go index 93aaa4d2..c400e48f 100644 --- a/user/types.go +++ b/user/types.go @@ -245,31 +245,18 @@ const ( // Config holds the configuration for the user Manager type Config struct { - Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite) - DatabaseURL string // Database connection string (PostgreSQL) - StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only) - DefaultAccess Permission // Default permission if no ACL matches - ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands - Users []*User // Predefined users to create on startup - Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant) - Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) - QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database - BcryptCost int // Cost of generated passwords; lowering makes testing faster - - // AccessCacheEnabled gates the in-memory ACL cache. When false (the - // default), authorizeTopicAccess runs the direct SQL query against the - // database on every call -- mutations and authorization are unaffected by - // any cache logic. When true, the Manager keeps an in-memory snapshot of - // user_access and serves authorizeTopicAccess from it; mutations refresh - // the affected slices, and a background poller picks up cross-process - // writes at AccessCacheReloadInterval. - AccessCacheEnabled bool - - // AccessCacheReloadInterval bounds the staleness of the in-memory ACL - // cache relative to writes from other processes (e.g. `ntfy access` CLI - // against a running server). Only honored when AccessCacheEnabled is true. - // Zero falls back to DefaultAccessCacheReloadInterval. - AccessCacheReloadInterval time.Duration + Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite) + DatabaseURL string // Database connection string (PostgreSQL) + StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only) + DefaultAccess Permission // Default permission if no ACL matches + ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands + Users []*User // Predefined users to create on startup + Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant) + Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) + QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database + BcryptCost int // Cost of generated passwords; lowering makes testing faster + AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only) + AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI } // Error constants used by the package From 4196e6444c680ca28054bf633e195d3ee847fec4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 21:31:23 -0400 Subject: [PATCH 480/548] Stringbuilder --- user/manager_sqlite.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index ebe81d8d..18cb4028 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -229,11 +229,16 @@ const ( // sqliteSelectAccessCacheUsersQuery builds the per-users cache-load query // with a "?, ?, ..." IN clause sized for n usernames. func sqliteSelectAccessCacheUsersQuery(n int) string { - placeholders := strings.Repeat(",?", n) - if n > 0 { - placeholders = placeholders[1:] // drop the leading comma + var sb strings.Builder + sb.WriteString(`SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id WHERE u.user IN (`) + for i := 0; i < n; i++ { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString("?") } - return `SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id WHERE u.user IN (` + placeholders + `)` + sb.WriteString(")") + return sb.String() } var sqliteQueries = queries{ From 7614405332d3a7cc70aeec8fb4d4d932906fe2a1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 31 May 2026 21:50:57 -0400 Subject: [PATCH 481/548] Wire up server.yml --- cmd/serve.go | 3 +++ docs/config.md | 2 ++ docs/releases.md | 4 ++++ server/server.yml | 3 +++ 4 files changed, 12 insertions(+) diff --git a/cmd/serve.go b/cmd/serve.go index 0cfc1cbc..9712f94f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,6 +52,7 @@ var flagsServe = append( altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "auth-access-cache", Aliases: []string{"auth_access_cache"}, EnvVars: []string{"NTFY_AUTH_ACCESS_CACHE"}, Value: user.DefaultAccessCacheEnabled, Usage: "enables the in-memory ACL cache (high-volume servers only)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), @@ -168,6 +169,7 @@ func execServe(c *cli.Context) error { authUsersRaw := c.StringSlice("auth-users") authAccessRaw := c.StringSlice("auth-access") authTokensRaw := c.StringSlice("auth-tokens") + authAccessCacheEnabled := c.Bool("auth-access-cache") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -468,6 +470,7 @@ func execServe(c *cli.Context) error { conf.AuthUsers = authUsers conf.AuthAccess = authAccess conf.AuthTokens = authTokens + conf.AuthAccessCacheEnabled = authAccessCacheEnabled conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/docs/config.md b/docs/config.md index c934143a..af6b9ec9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2284,6 +2284,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) | | `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). | | `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | +| `auth-access-cache` | `NTFY_AUTH_ACCESS_CACHE` | *bool* | false | Enables an in-memory snapshot of the access control list so authorization checks no longer hit the database. Off by default; only worth enabling on high-volume servers. ACL changes from a separate `ntfy access` CLI invocation against the same database become visible within ~60s; the server's own changes are immediate. | | `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) | | `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) | | `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header | @@ -2392,6 +2393,7 @@ OPTIONS: --auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE] --auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES] --auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] + --auth-access-cache, --auth_access_cache enables the in-memory ACL cache (high-volume servers only) (default: false) [$NTFY_AUTH_ACCESS_CACHE] --attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]) [$NTFY_ATTACHMENT_CACHE_DIR] --attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] diff --git a/docs/releases.md b/docs/releases.md index df18add5..2853f25d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1926,6 +1926,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ### ntfy server v2.24.0 (UNRELEASED) +**Features:** + +* Add opt-in in-memory ACL cache (`auth-access-cache`) that serves topic authorization without a database round-trip; off by default, intended for high-volume servers + **Bug fixes + maintenance:** * Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution) diff --git a/server/server.yml b/server/server.yml index 08161dc2..ee23dd8b 100644 --- a/server/server.yml +++ b/server/server.yml @@ -116,6 +116,8 @@ # - auth-tokens is a list of access tokens that are automatically created when the server starts. # Each entry is in the format ":[:

    (1x;v;g_ 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 StQg+Te>}T!009B;@9?sV@LtZ>-Ua{w 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