Compare commits

...
44 Commits
Author SHA1 Message Date
binwiederhier 2ad78edca1 Release notes 2026-03-16 20:13:39 -04:00
binwiederhier 9b1a32ec56 Refine 2026-03-16 15:52:57 -04:00
binwiederhier 3d9ce69042 PG races 2026-03-16 15:48:36 -04:00
binwiederhier 59ce581ba2 Fix postgres primary/replica races 2026-03-16 11:21:21 -04:00
binwiederhier df82fdf44c Add HTTP 413 to normal errors to not log 2026-03-16 10:27:23 -04:00
binwiederhier 3a37ea32f7 Webpush: Fix FK issue with Postgres 2026-03-16 10:24:16 -04:00
binwiederhier 6b38acb23a Route authorization query to read-only database replica to reduce primary database load 2026-03-15 22:01:19 -04:00
binwiederhier f5c255c53c Grr 2026-03-15 21:17:58 -04:00
binwiederhier fd0a49244e Disable test temporarily 2026-03-15 21:13:12 -04:00
binwiederhier 4699ed3ffd Fix UTF-8 insert failures in Postgres 2026-03-15 21:03:18 -04:00
Philipp C. Heckel 1afb99db67 Merge pull request #1658 from BradStaton/1657-postgresql-urls
Support `postgresql://` and `postgres://` URLs
2026-03-15 20:45:08 -04:00
binwiederhier 66208e6f88 Pre-import 2026-03-15 20:25:22 -04:00
BradStaton ce24594c32 Update serve.go
Support multiple postgres connection URL formats
2026-03-15 16:22:22 -04:00
binwiederhier 888850d8bc Add blurp 2026-03-15 10:29:07 -04:00
binwiederhier be09acd411 Bump 2026-03-15 10:26:03 -04:00
binwiederhier bf19a5be2d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-03-15 10:12:54 -04:00
BonifacioCalindoro fd8f356d1f 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/
2026-03-15 01:09:47 +01:00
binwiederhier 3296d158c5 Release notes 2026-03-14 15:07:11 -04:00
binwiederhier 45f045a5a4 Make config generator work on mobile 2026-03-14 14:39:51 -04:00
binwiederhier f7b6e9bbe3 Merge branch 'config-generator' 2026-03-14 14:21:18 -04:00
binwiederhier 22868f4742 Derp 2026-03-14 14:21:09 -04:00
Philipp C. Heckel 3801a28958 Merge pull request #1654 from binwiederhier/config-generator
Config generator
2026-03-14 14:16:51 -04:00
binwiederhier 2bf8f6271b Review 2026-03-14 14:15:46 -04:00
binwiederhier 13be9747e4 Security reivew 2026-03-14 13:56:43 -04:00
binwiederhier 26dd017401 Merge branch 'main' of github.com:binwiederhier/ntfy into config-generator 2026-03-14 13:47:42 -04:00
binwiederhier d00cd64220 Add admin user 2026-03-14 13:03:36 -04:00
binwiederhier fab08e862d More refining for config generator 2026-03-14 12:56:26 -04:00
binwiederhier 143935b917 More refining 2026-03-14 08:42:07 -04:00
Philipp C. Heckel a82ede8a14 Merge pull request #1648 from binwiederhier/postgres-replica
Add PostgreSQL read-only replica support
2026-03-12 21:28:38 -04:00
binwiederhier 8a34dfe3f8 Move things, rename things 2026-03-12 21:17:30 -04:00
binwiederhier 270fec51a6 Bump 2026-03-11 22:12:10 -04:00
binwiederhier 9eaadd74cf Log 2026-03-11 22:09:00 -04:00
binwiederhier 1f483dcbd3 Remove consts 2026-03-11 21:54:35 -04:00
binwiederhier 85bdfc61ce Refine, log unhealthy replica 2026-03-11 21:07:58 -04:00
binwiederhier ac65df1e83 Move auth queries to primary, redo health check loop 2026-03-11 20:26:29 -04:00
binwiederhier ab33ac7ae5 Refine 2026-03-11 11:58:40 -04:00
binwiederhier f1865749d7 WIP: Postgres read-only replica 2026-03-10 22:17:40 -04:00
binwiederhier 997e20fa3f Better error message for database-url errors 2026-03-10 21:18:34 -04:00
binwiederhier 3402510b47 More config generator 2026-03-10 21:11:27 -04:00
binwiederhier 19d1618bb8 Continued 2026-03-08 22:00:08 -04:00
binwiederhier 612afb1435 Configurator 2026-03-08 19:32:54 -04:00
binwiederhier 2b36ad9eb9 config generator 2026-03-08 18:59:17 -04:00
binwiederhier bcd07115c2 Add tooltips for edit/delete buttons 2026-03-08 18:30:11 -04:00
binwiederhier 109271a930 Avoid playing sound more than every 2s 2026-03-08 10:55:59 -04:00
46 changed files with 4890 additions and 401 deletions
+5 -3
View File
@@ -2,9 +2,6 @@ package cmd
import ( import (
"fmt" "fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/util"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -14,9 +11,14 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/util"
) )
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
t.Skip("temporarily disabled") // FIXME
testMessage := util.RandomString(10) testMessage := util.RandomString(10)
app, _, _, _ := newTestApp() app, _, _, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
+8 -1
View File
@@ -40,6 +40,7 @@ var flagsServe = append(
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: "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: "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: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "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-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.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)"}), 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)"}),
@@ -145,6 +146,7 @@ func execServe(c *cli.Context) error {
certFile := c.String("cert-file") certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file") firebaseKeyFile := c.String("firebase-key-file")
databaseURL := c.String("database-url") databaseURL := c.String("database-url")
databaseReplicaURLs := c.StringSlice("database-replica-urls")
webPushPrivateKey := c.String("web-push-private-key") webPushPrivateKey := c.String("web-push-private-key")
webPushPublicKey := c.String("web-push-public-key") webPushPublicKey := c.String("web-push-public-key")
webPushFile := c.String("web-push-file") webPushFile := c.String("web-push-file")
@@ -282,8 +284,12 @@ func execServe(c *cli.Context) error {
} }
// Check values // Check values
if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") { if databaseURL != "" && !strings.HasPrefix(databaseURL, "postgres://") && !strings.HasPrefix(databaseURL, "postgresql://") {
return errors.New("if database-url is set, it must start with postgres:// or postgresql://")
} else 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") return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set")
} else if len(databaseReplicaURLs) > 0 && databaseURL == "" {
return errors.New("database-replica-urls can only be used if database-url is also set")
} else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { } else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist") return errors.New("if set, FCM key file must exist")
} else if firebaseKeyFile != "" && !server.FirebaseAvailable { } else if firebaseKeyFile != "" && !server.FirebaseAvailable {
@@ -502,6 +508,7 @@ func execServe(c *cli.Context) error {
conf.MetricsListenHTTP = metricsListenHTTP conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP conf.ProfileListenHTTP = profileListenHTTP
conf.DatabaseURL = databaseURL conf.DatabaseURL = databaseURL
conf.DatabaseReplicaURLs = databaseReplicaURLs
conf.WebPushPrivateKey = webPushPrivateKey conf.WebPushPrivateKey = webPushPrivateKey
conf.WebPushPublicKey = webPushPublicKey conf.WebPushPublicKey = webPushPublicKey
conf.WebPushFile = webPushFile conf.WebPushFile = webPushFile
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
@@ -379,11 +380,11 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
} }
if databaseURL != "" { if databaseURL != "" {
pool, dbErr := pg.Open(databaseURL) host, dbErr := pg.Open(databaseURL)
if dbErr != nil { if dbErr != nil {
return nil, dbErr return nil, dbErr
} }
return user.NewPostgresManager(pool, authConfig) return user.NewPostgresManager(db.New(host, nil), authConfig)
} else if authFile != "" { } else if authFile != "" {
if !util.FileExists(authFile) { if !util.FileExists(authFile) {
return nil, errors.New("auth-file does not exist; please start the server at least once to create it") return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
+126 -27
View File
@@ -1,38 +1,137 @@
package db package db
import ( import (
"context"
"database/sql" "database/sql"
"sync/atomic"
"time"
"heckel.io/ntfy/v2/log"
) )
// ExecTx executes a function within a database transaction. If the function returns an error, const (
// the transaction is rolled back. Otherwise, the transaction is committed. tag = "db"
func ExecTx(db *sql.DB, f func(tx *sql.Tx) error) error { replicaHealthCheckInitialDelay = 5 * time.Second
tx, err := db.Begin() replicaHealthCheckInterval = 30 * time.Second
if err != nil { replicaHealthCheckTimeout = 10 * time.Second
return err )
}
defer tx.Rollback() // DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods
if err := f(tx); err != nil { // delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica
return err // (round-robin), falling back to the primary if no replicas are configured or all are unhealthy.
} type DB struct {
return tx.Commit() primary *Host
replicas []*Host
counter atomic.Uint64
cancel context.CancelFunc
} }
// QueryTx executes a function within a database transaction and returns the result. If the function // New creates a new DB that wraps the given primary and optional replica connections.
// returns an error, the transaction is rolled back. Otherwise, the transaction is committed. // If replicas is nil or empty, ReadOnly() simply returns the primary.
func QueryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) { // Replicas start unhealthy and are checked immediately by a background goroutine.
tx, err := db.Begin() func New(primary *Host, replicas []*Host) *DB {
if err != nil { ctx, cancel := context.WithCancel(context.Background())
var zero T d := &DB{
return zero, err primary: primary,
replicas: replicas,
cancel: cancel,
} }
defer tx.Rollback() if len(d.replicas) > 0 {
t, err := f(tx) go d.healthCheckLoop(ctx)
if err != nil { }
return t, err return d
}
// Query delegates to the primary database.
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
return d.primary.DB.Query(query, args...)
}
// QueryRow delegates to the primary database.
func (d *DB) QueryRow(query string, args ...any) *sql.Row {
return d.primary.DB.QueryRow(query, args...)
}
// Exec delegates to the primary database.
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
return d.primary.DB.Exec(query, args...)
}
// Begin delegates to the primary database.
func (d *DB) Begin() (*sql.Tx, error) {
return d.primary.DB.Begin()
}
// Ping delegates to the primary database.
func (d *DB) Ping() error {
return d.primary.DB.Ping()
}
// Primary returns the underlying primary *sql.DB. This is only intended for
// one-time schema setup during store initialization, not for regular queries.
func (d *DB) Primary() *sql.DB {
return d.primary.DB
}
// ReadOnly returns a *sql.DB suitable for read-only queries. It round-robins across healthy
// replicas. If all replicas are unhealthy or none are configured, the primary is returned.
func (d *DB) ReadOnly() *sql.DB {
if len(d.replicas) == 0 {
return d.primary.DB
}
n := len(d.replicas)
start := int(d.counter.Add(1) - 1)
for i := 0; i < n; i++ {
r := d.replicas[(start+i)%n]
if r.healthy.Load() {
return r.DB
}
}
return d.primary.DB
}
// Close closes the primary database and all replicas, and stops the health-check goroutine.
func (d *DB) Close() error {
d.cancel()
for _, r := range d.replicas {
r.DB.Close()
}
return d.primary.DB.Close()
}
// healthCheckLoop checks replicas immediately, then periodically on a ticker.
func (d *DB) healthCheckLoop(ctx context.Context) {
select {
case <-ctx.Done():
return
case <-time.After(replicaHealthCheckInitialDelay):
d.checkReplicas(ctx)
}
for {
select {
case <-ctx.Done():
return
case <-time.After(replicaHealthCheckInterval):
d.checkReplicas(ctx)
}
}
}
// checkReplicas pings each replica with a timeout and updates its health status.
func (d *DB) checkReplicas(ctx context.Context) {
for _, r := range d.replicas {
wasHealthy := r.healthy.Load()
pingCtx, cancel := context.WithTimeout(ctx, replicaHealthCheckTimeout)
err := r.DB.PingContext(pingCtx)
cancel()
if err != nil {
r.healthy.Store(false)
log.Tag(tag).Error("Database replica %s is unhealthy: %s", r.Addr, err)
} else {
r.healthy.Store(true)
if !wasHealthy {
log.Tag(tag).Info("Database replica %s is healthy", r.Addr)
}
}
} }
if err := tx.Commit(); err != nil {
return t, err
}
return t, nil
} }
+49 -22
View File
@@ -5,65 +5,84 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
"heckel.io/ntfy/v2/db"
) )
const ( // Open opens a PostgreSQL connection pool for a primary database. It pings the database
paramMaxOpenConns = "pool_max_conns" // to verify connectivity before returning.
paramMaxIdleConns = "pool_max_idle_conns" func Open(dsn string) (*db.Host, error) {
paramConnMaxLifetime = "pool_conn_max_lifetime" d, err := open(dsn)
paramConnMaxIdleTime = "pool_conn_max_idle_time" if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := d.DB.Ping(); err != nil {
return nil, fmt.Errorf("database ping failed on %v: %w", d.Addr, err)
}
return d, nil
}
defaultMaxOpenConns = 10 // OpenReplica opens a PostgreSQL connection pool for a read replica. Unlike Open, it does
) // not ping the database, since replicas are health-checked in the background by db.DB.
func OpenReplica(dsn string) (*db.Host, error) {
return open(dsn)
}
// Open 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, // 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 // pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from
// the DSN before passing it to the driver. // the DSN before passing it to the driver.
func Open(dsn string) (*sql.DB, error) { func open(dsn string) (*db.Host, error) {
u, err := url.Parse(dsn) u, err := url.Parse(dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid database URL: %w", err) return nil, fmt.Errorf("invalid database URL: %w", err)
} }
switch u.Scheme {
case "postgres", "postgresql":
// OK
default:
return nil, fmt.Errorf("invalid database URL scheme %q, must be \"postgres\" or \"postgresql\" (URL: %s)", u.Scheme, censorPassword(u))
}
q := u.Query() q := u.Query()
maxOpenConns, err := extractIntParam(q, paramMaxOpenConns, defaultMaxOpenConns) maxOpenConns, err := extractIntParam(q, "pool_max_conns", 10)
if err != nil { if err != nil {
return nil, err return nil, err
} }
maxIdleConns, err := extractIntParam(q, paramMaxIdleConns, 0) maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0)
if err != nil { if err != nil {
return nil, err return nil, err
} }
connMaxLifetime, err := extractDurationParam(q, paramConnMaxLifetime, 0) connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0)
if err != nil { if err != nil {
return nil, err return nil, err
} }
connMaxIdleTime, err := extractDurationParam(q, paramConnMaxIdleTime, 0) connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0)
if err != nil { if err != nil {
return nil, err return nil, err
} }
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
db, err := sql.Open("pgx", u.String()) d, err := sql.Open("pgx", u.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
db.SetMaxOpenConns(maxOpenConns) d.SetMaxOpenConns(maxOpenConns)
if maxIdleConns > 0 { if maxIdleConns > 0 {
db.SetMaxIdleConns(maxIdleConns) d.SetMaxIdleConns(maxIdleConns)
} }
if connMaxLifetime > 0 { if connMaxLifetime > 0 {
db.SetConnMaxLifetime(connMaxLifetime) d.SetConnMaxLifetime(connMaxLifetime)
} }
if connMaxIdleTime > 0 { if connMaxIdleTime > 0 {
db.SetConnMaxIdleTime(connMaxIdleTime) d.SetConnMaxIdleTime(connMaxIdleTime)
} }
if err := db.Ping(); err != nil { return &db.Host{
return nil, fmt.Errorf("ping failed: %w", err) Addr: u.Host,
} DB: d,
return db, nil }, nil
} }
func extractIntParam(q url.Values, key string, defaultValue int) (int, error) { func extractIntParam(q url.Values, key string, defaultValue int) (int, error) {
@@ -79,6 +98,14 @@ func extractIntParam(q url.Values, key string, defaultValue int) (int, error) {
return v, nil return v, nil
} }
// censorPassword returns a string representation of the URL with the password replaced by "*****".
func censorPassword(u *url.URL) string {
if password, hasPassword := u.User.Password(); hasPassword {
return strings.Replace(u.String(), ":"+password+"@", ":*****@", 1)
}
return u.String()
}
func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) { func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) {
s := q.Get(key) s := q.Get(key)
if s == "" { if s == "" {
+53
View File
@@ -0,0 +1,53 @@
package pg
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
)
func TestOpen_InvalidScheme(t *testing.T) {
_, err := Open("postgresql+psycopg2://user:pass@localhost/db")
require.Error(t, err)
require.Contains(t, err.Error(), `invalid database URL scheme "postgresql+psycopg2"`)
require.Contains(t, err.Error(), "*****")
require.NotContains(t, err.Error(), "pass")
}
func TestOpen_InvalidURL(t *testing.T) {
_, err := Open("not a valid url\x00")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid database URL")
}
func TestCensorPassword(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "with password",
url: "postgres://user:secret@localhost/db",
expected: "postgres://user:*****@localhost/db",
},
{
name: "without password",
url: "postgres://localhost/db",
expected: "postgres://localhost/db",
},
{
name: "user only",
url: "postgres://user@localhost/db",
expected: "postgres://user@localhost/db",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.url)
require.NoError(t, err)
require.Equal(t, tt.expected, censorPassword(u))
})
}
}
+13 -12
View File
@@ -1,13 +1,13 @@
package dbtest package dbtest
import ( import (
"database/sql"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -30,34 +30,35 @@ func CreateTestPostgresSchema(t *testing.T) string {
q.Set("pool_max_conns", testPoolMaxConns) q.Set("pool_max_conns", testPoolMaxConns)
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
dsn = u.String() dsn = u.String()
setupDB, err := pg.Open(dsn) setupHost, err := pg.Open(dsn)
require.Nil(t, err) require.Nil(t, err)
_, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema)) _, err = setupHost.DB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, setupDB.Close()) require.Nil(t, setupHost.DB.Close())
q.Set("search_path", schema) q.Set("search_path", schema)
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
schemaDSN := u.String() schemaDSN := u.String()
t.Cleanup(func() { t.Cleanup(func() {
cleanDB, err := pg.Open(dsn) cleanHost, err := pg.Open(dsn)
if err == nil { if err == nil {
cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) cleanHost.DB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
cleanDB.Close() cleanHost.DB.Close()
} }
}) })
return schemaDSN return schemaDSN
} }
// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *sql.DB connection to it. // CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it.
// It registers cleanup functions to close the DB and drop the schema when the test finishes. // 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. // If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
func CreateTestPostgres(t *testing.T) *sql.DB { func CreateTestPostgres(t *testing.T) *db.DB {
t.Helper() t.Helper()
schemaDSN := CreateTestPostgresSchema(t) schemaDSN := CreateTestPostgresSchema(t)
testDB, err := pg.Open(schemaDSN) testHost, err := pg.Open(schemaDSN)
require.Nil(t, err) require.Nil(t, err)
d := db.New(testHost, nil)
t.Cleanup(func() { t.Cleanup(func() {
testDB.Close() d.Close()
}) })
return testDB return d
} }
+25
View File
@@ -0,0 +1,25 @@
package db
import (
"database/sql"
"sync/atomic"
)
// Beginner is an interface for types that can begin a database transaction.
// Both *sql.DB and *DB implement this.
type Beginner interface {
Begin() (*sql.Tx, error)
}
// Querier is an interface for types that can execute SQL queries.
// *sql.DB, *sql.Tx, and *DB all implement this.
type Querier interface {
Query(query string, args ...any) (*sql.Rows, error)
}
// Host pairs a *sql.DB with the host:port it was opened against.
type Host struct {
Addr string // "host:port"
DB *sql.DB
healthy atomic.Bool
}
+36
View File
@@ -0,0 +1,36 @@
package db
import "database/sql"
// 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 Beginner, 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 Beginner, 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
}
+298 -11
View File
@@ -135,6 +135,268 @@ using Docker Compose (i.e. `docker-compose.yml`):
command: serve command: serve
``` ```
## Config generator
This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point. Please refer to the relevant sections in the doc for more details.
<div style="text-align: center;">
<button type="button" id="cg-open-btn" class="cg-open-btn">Open config generator</button>
</div>
<figure markdown style="padding-left: 50px; padding-right: 50px; cursor: pointer;" onclick="document.getElementById('cg-open-btn').click();">
<img src="../../static/img/config-generator.png"/>
<figcaption>The config generator helps you create a custom config for your self-hosted ntfy instance. Click to open.</figcaption>
</figure>
<div id="cg-modal" class="cg-modal" style="display:none">
<div class="cg-modal-backdrop"></div>
<div class="cg-modal-dialog">
<div class="cg-modal-header">
<div class="cg-modal-header-left">
<span class="cg-modal-title">Config generator</span><span class="cg-badge-beta">BETA</span>
<span class="cg-modal-desc">This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point.</span>
</div>
<div class="cg-modal-header-actions">
<button type="button" id="cg-reset-btn" class="cg-modal-reset" title="Reset all values">Reset</button>
<button type="button" id="cg-close-btn" class="cg-modal-close" title="Close">&times;</button>
</div>
</div>
<div class="cg-modal-body">
<div class="cg-mobile-toggle">
<button class="cg-mobile-toggle-btn active" data-show="left">Edit</button>
<button class="cg-mobile-toggle-btn" data-show="right">Preview</button>
</div>
<div id="cg-left">
<div class="cg-nav">
<div class="cg-nav-tab active" data-panel="cg-panel-general">General</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-database" id="cg-nav-database">Database</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-auth" id="cg-nav-auth">Users</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-cache" id="cg-nav-cache">Message Cache</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-attach" id="cg-nav-attach">Attachments</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-webpush" id="cg-nav-webpush">Web Push</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-email" id="cg-nav-email">Email</div>
</div>
<div class="cg-panels">
<div class="cg-panel active" id="cg-panel-general">
<div class="cg-field cg-inline-field">
<label>What URL will ntfy be reachable on?</label>
<input type="text" data-key="base-url" placeholder="https://ntfy.example.com">
</div>
<div class="cg-field cg-inline-field">
<label>Will ntfy run behind a proxy (e.g. nginx, Caddy)? <a href="/config/#behind-a-proxy-tls-etc" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-proxy" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-proxy" value="yes"><span>Yes</span></label>
</div>
</div>
<div class="cg-field cg-inline-field">
<label>Will this ntfy server be open or private? <a href="/config/#access-control" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-server-type" value="open" checked><span>Open</span></label>
<label><input type="radio" name="cg-server-type" value="private"><span>Private</span></label>
<label><input type="radio" name="cg-server-type" value="custom"><span>Custom</span></label>
</div>
</div>
<div class="cg-field cg-inline-field">
<label>Will iOS/iPhone users use this server? <a href="/config/#ios-instant-notifications" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-ios" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-ios" value="yes"><span>Yes</span></label>
</div>
</div>
<div class="cg-field cg-inline-field">
<label>Do you want to use ntfy as a UnifiedPush distributor? <a href="/config/#example-unifiedpush" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-unifiedpush" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-unifiedpush" value="yes"><span>Yes</span></label>
</div>
</div>
<div class="cg-field">
<label>Which features do you want to enable?</label>
<div class="cg-feature-grid">
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-auth"> User management and access control</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-auth">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-cache"> Persistent message cache</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-cache">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-attach"> Attachments</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-attach">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-webpush"> Web push</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-webpush">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-smtp-out"> Email notifications</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-email">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-smtp-in"> Email publishing</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-email">Configure</button></div>
</div>
</div>
<div class="cg-field cg-inline-field cg-hidden" id="cg-wizard-db">
<label>Which database backend would you like to use? <a href="/config/#database-options" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-db-type" value="sqlite" checked><span>SQLite</span></label>
<label><input type="radio" name="cg-db-type" value="postgres"><span>PostgreSQL</span></label>
</div>
</div>
</div>
<div class="cg-panel" id="cg-panel-auth">
<div class="cg-panel-desc">Configure user management, access control, and provisioned users/ACLs. See <a href="/config/#access-control" target="_blank">access control</a> for details.</div>
<div class="cg-field cg-inline-field">
<label>Where should the user database be stored?</label>
<input type="text" data-key="auth-file" placeholder="/var/lib/ntfy/auth.db">
</div>
<div class="cg-field cg-inline-field">
<label>What should the default access policy be? <a href="/config/#access-control" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<select id="cg-default-access-select">
<option value="read-write" selected>Read &amp; Write</option>
<option value="read-only">Read Only</option>
<option value="write-only">Write Only</option>
<option value="deny-all">Deny All</option>
</select>
</div>
<div class="cg-field cg-inline-field">
<label>Should login to the web app be enabled?</label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-login-mode" value="disabled" checked><span>Disabled</span></label>
<label><input type="radio" name="cg-login-mode" value="enabled"><span>Enabled</span></label>
<label><input type="radio" name="cg-login-mode" value="required"><span>Required</span></label>
</div>
</div>
<div class="cg-field cg-inline-field">
<label>Should it be possible to sign up via the web app?</label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-enable-signup" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-enable-signup" value="yes"><span>Yes</span></label>
</div>
</div>
<input type="hidden" data-key="auth-default-access">
<input type="checkbox" data-key="enable-login" id="cg-enable-login-hidden" style="display:none">
<input type="checkbox" data-key="require-login" id="cg-require-login-hidden" style="display:none">
<input type="checkbox" data-key="enable-signup" id="cg-enable-signup-hidden" style="display:none">
<div class="cg-field">
<label>Provisioned users <a href="/config/#users-and-roles" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-repeatable-container" id="cg-auth-users-container"></div>
<button type="button" class="cg-btn-add" data-add-type="user">+ Add user</button>
</div>
<div class="cg-field">
<label>Provisioned ACLs <a href="/config/#access-control-list-acl" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-repeatable-container" id="cg-auth-acls-container"></div>
<button type="button" class="cg-btn-add" data-add-type="acl">+ Add ACL</button>
</div>
<div class="cg-field">
<label>Provisioned tokens <a href="/config/#access-tokens" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-repeatable-container" id="cg-auth-tokens-container"></div>
<button type="button" class="cg-btn-add" data-add-type="token">+ Add token</button>
</div>
</div>
<div class="cg-panel" id="cg-panel-cache">
<div class="cg-panel-desc">Configure the message cache to allow devices to retrieve missed notifications. See <a href="/config/#message-cache" target="_blank">message cache</a> for details.</div>
<div class="cg-field cg-inline-field" id="cg-cache-file-field">
<label>Where should the cache be stored?</label>
<input type="text" data-key="cache-file" placeholder="/var/cache/ntfy/cache.db">
</div>
<div class="cg-field cg-inline-field">
<label>How long should messages be cached?</label>
<input type="text" data-key="cache-duration" placeholder="12h">
</div>
</div>
<div class="cg-panel" id="cg-panel-attach">
<div class="cg-panel-desc">Allow users to upload and attach files to notifications. See <a href="/config/#attachments" target="_blank">attachments</a> for details.</div>
<div class="cg-field cg-inline-field">
<label>Where should attachments be stored?</label>
<input type="text" data-key="attachment-cache-dir" placeholder="/var/cache/ntfy/attachments">
</div>
<div class="cg-field cg-inline-field">
<label>Max file size per attachment?</label>
<input type="text" data-key="attachment-file-size-limit" placeholder="15M">
</div>
<div class="cg-field cg-inline-field">
<label>Total attachment storage limit?</label>
<input type="text" data-key="attachment-total-size-limit" placeholder="5G">
</div>
<div class="cg-field cg-inline-field">
<label>How long before attachments expire?</label>
<input type="text" data-key="attachment-expiry-duration" placeholder="3h">
</div>
</div>
<div class="cg-panel" id="cg-panel-webpush">
<div class="cg-panel-desc">Enable browser push notifications via the Web Push API. VAPID keys are generated automatically. See <a href="/config/#web-push" target="_blank">web push</a> for details.</div>
<div class="cg-field cg-inline-field">
<label>Where should web push data be stored?</label>
<input type="text" data-key="web-push-file" placeholder="/var/lib/ntfy/webpush.db">
</div>
<div class="cg-field cg-inline-field">
<label>Contact email address</label>
<input type="text" data-key="web-push-email-address" placeholder="admin@example.com">
</div>
<div class="cg-field cg-inline-field">
<label>Private key</label>
<input type="text" data-key="web-push-private-key" placeholder="Auto-generated" readonly>
</div>
<div class="cg-field cg-inline-field">
<label>Public key</label>
<input type="text" data-key="web-push-public-key" placeholder="Auto-generated" readonly>
</div>
<div class="cg-field cg-inline-field">
<label></label>
<button type="button" id="cg-regen-keys" class="cg-btn-add">Regenerate keys</button>
</div>
</div>
<div class="cg-panel" id="cg-panel-email">
<div class="cg-panel-desc">Configure outgoing email notifications and/or incoming email publishing. See <a href="/config/#e-mail-notifications" target="_blank">email notifications</a> and <a href="/config/#e-mail-publishing" target="_blank">email publishing</a> for details.</div>
<div id="cg-email-out-section" class="cg-hidden">
<div class="cg-field"><label><strong>Outgoing (notifications)</strong></label></div>
<div class="cg-field cg-inline-field">
<label>SMTP server address</label>
<input type="text" data-key="smtp-sender-addr" placeholder="smtp.example.com:587">
</div>
<div class="cg-field cg-inline-field">
<label>Sender email</label>
<input type="text" data-key="smtp-sender-from" placeholder="ntfy@example.com">
</div>
<div class="cg-field cg-inline-field">
<label>SMTP username</label>
<input type="text" data-key="smtp-sender-user" placeholder="Username">
</div>
<div class="cg-field cg-inline-field">
<label>SMTP password</label>
<input type="password" data-key="smtp-sender-pass" placeholder="Password">
</div>
</div>
<div id="cg-email-in-section" class="cg-hidden">
<div class="cg-field"><label><strong>Incoming (publishing)</strong></label></div>
<div class="cg-field cg-inline-field">
<label>Listen address</label>
<input type="text" data-key="smtp-server-listen" placeholder=":25">
</div>
<div class="cg-field cg-inline-field">
<label>Domain</label>
<input type="text" data-key="smtp-server-domain" placeholder="ntfy.example.com">
</div>
<div class="cg-field cg-inline-field">
<label>Address prefix</label>
<input type="text" data-key="smtp-server-addr-prefix" placeholder="ntfy-">
</div>
</div>
</div>
<div class="cg-panel" id="cg-panel-database">
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql-experimental" target="_blank">PostgreSQL</a> for details.</div>
<div class="cg-field">
<label>Database URL</label>
<input type="text" data-key="database-url" placeholder="postgres://user:pass@host:5432/ntfy">
</div>
</div>
<input type="hidden" data-key="upstream-base-url">
<input type="checkbox" data-key="behind-proxy" id="cg-behind-proxy" style="display:none">
</div>
</div>
<div id="cg-right">
<div class="cg-output-tabs">
<div class="cg-output-tab active" data-format="server-yml">server.yml</div>
<div class="cg-output-tab" data-format="docker-compose">docker-compose.yml</div>
<div class="cg-output-tab" data-format="env-vars">Env variables</div>
<button type="button" id="cg-copy-btn" class="cg-btn-copy" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
</div>
<div class="cg-output-wrap">
<pre><code id="cg-code"><span class="cg-empty-msg">Configure options on the left to generate your config...</span></code></pre>
<div id="cg-warnings" class="cg-hidden"></div>
</div>
</div>
</div>
</div>
</div>
## Database options ## 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. 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. You can choose between **SQLite** and **PostgreSQL** as the database backend.
@@ -149,11 +411,7 @@ no external dependencies:
### PostgreSQL (EXPERIMENTAL) ### PostgreSQL (EXPERIMENTAL)
As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the 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: `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), 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`, [access control](#access-control), and [web push](#web-push) subscriptions instead of SQLite. The `cache-file`,
@@ -165,11 +423,44 @@ 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 also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`.
To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas
using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages, checking access permissions, etc)
are distributed across the replicas using round-robin, while all writes and correctness-critical reads continue to go
to the primary. If a replica becomes unhealthy, ntfy automatically falls back to the primary until the replica recovers.
You can also set this via the environment variable `NTFY_DATABASE_REPLICA_URLS` (comma-separated) or the command line
flag `--database-replica-urls`.
Examples:
=== "Simple"
```yaml
database-url: "postgres://user:pass@host:5432/ntfy"
```
=== "With SSL and pool tuning"
```yaml
database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m"
```
=== "With CA certificate"
```yaml
database-url: "postgres://user:pass@host:25060/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30"
```
=== "With read replicas"
```yaml
database-url: "postgres://user:pass@primary:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30"
database-replica-urls:
- "postgres://user:pass@replica1:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30"
- "postgres://user:pass@replica2:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30"
```
The database URL supports the standard [PostgreSQL connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS) 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`. 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. 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: In addition, ntfy supports the following custom query parameters to tune the connection pool (these apply to both
the primary and replica URLs):
| Parameter | Default | Description | | Parameter | Default | Description |
|---------------------------|---------|----------------------------------------------------------------------------------| |---------------------------|---------|----------------------------------------------------------------------------------|
@@ -178,11 +469,6 @@ In addition, ntfy supports the following custom query parameters to tune the con
| `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) | | `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`) | | `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?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m"
```
## Message cache ## Message cache
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
@@ -1819,6 +2105,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate 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). | | `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 [database options](#database-options). | | `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). |
| `database-replica-urls` | `NTFY_DATABASE_REPLICA_URLS` | *list of strings (connection URLs)* | - | PostgreSQL read replica connection strings. Non-critical read-only queries are distributed across replicas (round-robin) with automatic fallback to primary. Requires `database-url`. See [read replicas](#read-replicas). |
| `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-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-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) | | `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) |
+30 -30
View File
@@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.tar.gz
tar zxvf ntfy_2.18.0_linux_amd64.tar.gz tar zxvf ntfy_2.19.2_linux_amd64.tar.gz
sudo cp -a ntfy_2.18.0_linux_amd64/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.19.2_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 mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.tar.gz
tar zxvf ntfy_2.18.0_linux_armv6.tar.gz tar zxvf ntfy_2.19.2_linux_armv6.tar.gz
sudo cp -a ntfy_2.18.0_linux_armv6/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.19.2_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.tar.gz
tar zxvf ntfy_2.18.0_linux_armv7.tar.gz tar zxvf ntfy_2.19.2_linux_armv7.tar.gz
sudo cp -a ntfy_2.18.0_linux_armv7/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.19.2_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.tar.gz
tar zxvf ntfy_2.18.0_linux_arm64.tar.gz tar zxvf ntfy_2.19.2_linux_arm64.tar.gz
sudo cp -a ntfy_2.18.0_linux_arm64/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.19.2_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
@@ -116,7 +116,7 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -124,7 +124,7 @@ Manually installing the .deb file:
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -132,7 +132,7 @@ Manually installing the .deb file:
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -140,7 +140,7 @@ Manually installing the .deb file:
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -150,28 +150,28 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv6" === "armv6"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "arm64" === "arm64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
@@ -213,18 +213,18 @@ pkg install go-ntfy
## macOS ## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. 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.18.0/ntfy_2.18.0_darwin_all.tar.gz), To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). 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 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). `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash ```bash
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 curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz > ntfy_2.19.2_darwin_all.tar.gz
tar zxvf ntfy_2.18.0_darwin_all.tar.gz tar zxvf ntfy_2.19.2_darwin_all.tar.gz
sudo cp -a ntfy_2.18.0_darwin_all/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.19.2_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.18.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml cp ntfy_2.19.2_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help 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. 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, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_windows_amd64.zip), * [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. 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` * Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
+47 -4
View File
@@ -6,13 +6,56 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date | | Component | Version | Release date |
|------------------|---------|--------------| |------------------|---------|--------------|
| ntfy server | v2.18.0 | Mar 7, 2026 | | ntfy server | v2.19.2 | Mar 16, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.3 | Nov 26, 2023 | | ntfy iOS app | v1.3 | Nov 26, 2023 |
Please check out the release notes for [upcoming releases](#not-released-yet) below. Please check out the release notes for [upcoming releases](#not-released-yet) below.
### ntfy server v2.18.0 ### ntfy server v2.19.2
Released March 16, 2026
This is another small bugfix release for PostgreSQL, avoiding races between primary and read replica, as well as to
further reduce primary load.
**Bug fixes + maintenance:**
* Fix race condition in web push subscription causing FK constraint violation when concurrent requests hit the same endpoint
* Route authorization query to read-only database replica to reduce primary database load
## ntfy server v2.19.1
Released March 15, 2026
This is a bugfix release to avoid PostgreSQL insert failures due to invalid UTF-8 messages. It also fixes `database-url`
validation incorrectly rejecting `postgresql://` connection strings.
**Bug fixes + maintenance:**
* Fix invalid UTF-8 in HTTP headers (e.g. Latin-1 encoded text) causing PostgreSQL insert failures and dropping entire message batches
* Fix `database-url` validation rejecting `postgresql://` connection strings ([#1657](https://github.com/binwiederhier/ntfy/issues/1657)/[#1658](https://github.com/binwiederhier/ntfy/pull/1658))
## ntfy server v2.19.0
Released March 15, 2026
This is a fast-follow release that enables Postgres read replica support.
To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas
using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages,
checking access permissions, etc) are distributed across the replicas using round-robin, while all writes and
correctness-critical reads continue to go to the primary. If a replica becomes unhealthy, ntfy automatically falls back
to the primary until the replica recovers.
**Features:**
* Support [PostgreSQL read replicas](config.md#postgresql-experimental) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
**Bug fixes + maintenance:**
* 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)
## ntfy server v2.18.0
Released March 7, 2026 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 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 +76,7 @@ if things are working (or not working). There is a [one-off migration tool](http
* Preserve `<br>` 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) * Preserve `<br>` 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 Released March 5, 2026
This is a tiny release that will revert the "reconnecting ..." behavior of the foreground notification. Lots of people This is a tiny release that will revert the "reconnecting ..." behavior of the foreground notification. Lots of people
@@ -1755,4 +1798,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
_None_ Nothing.
+853
View File
@@ -0,0 +1,853 @@
/* Config Generator */
/* Hidden utility */
.cg-hidden {
display: none !important;
}
/* Open button */
.cg-open-btn {
display: inline-block;
padding: 8px 20px;
background: var(--md-primary-fg-color);
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.cg-open-btn:hover {
opacity: 0.85;
}
/* Modal overlay */
.cg-modal {
position: fixed;
inset: 0;
z-index: 1000;
}
.cg-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.cg-modal-dialog {
position: absolute;
inset: 24px;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
overflow: hidden;
font-size: 0.78rem;
}
.cg-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
}
.cg-modal-header-left {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.cg-modal-title {
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
}
.cg-badge-beta {
display: inline-block;
padding: 1px 8px;
margin-left: 8px;
background: var(--md-primary-fg-color);
color: #fff;
font-size: 0.6rem;
font-weight: 600;
border-radius: 10px;
letter-spacing: 0.5px;
vertical-align: middle;
}
.cg-modal-desc {
font-size: 0.75rem;
color: #888;
}
.cg-modal-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.cg-modal-reset {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.75rem;
color: #777;
cursor: pointer;
padding: 4px 12px;
font-family: inherit;
transition: color 0.15s, border-color 0.15s;
}
.cg-modal-reset:hover {
color: #333;
border-color: #999;
}
.cg-modal-close {
background: none;
border: none;
font-size: 1.4rem;
color: #999;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.cg-modal-close:hover {
color: #333;
}
/* Modal body: left + right */
.cg-modal-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Left panel */
#cg-left {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #ddd;
min-width: 0;
}
.cg-nav {
display: flex;
flex-wrap: wrap;
gap: 0;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
padding: 0 16px;
}
.cg-nav-tab {
padding: 9px 14px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
color: #777;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
user-select: none;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.cg-nav-tab:hover {
color: #444;
}
.cg-nav-tab.active {
color: var(--md-primary-fg-color);
border-bottom-color: var(--md-primary-fg-color);
}
.cg-panels {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.cg-panel {
display: none;
}
.cg-panel.active {
display: block;
}
.cg-panel-desc {
font-size: 0.75rem;
color: #888;
margin-bottom: 12px;
line-height: 1.5;
}
.cg-panel-desc a {
color: var(--md-primary-fg-color);
}
.cg-help {
color: var(--md-primary-fg-color);
text-decoration: none;
margin-left: 4px;
vertical-align: middle;
flex-shrink: 0;
transition: color 0.15s;
}
.cg-help:hover {
color: var(--md-primary-fg-color--dark, #2a6e5f);
}
/* Right panel */
#cg-right {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.cg-output-tabs {
display: flex;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
padding: 0 16px;
}
.cg-output-tab {
padding: 9px 14px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
color: #777;
transition: color 0.15s, border-color 0.15s;
user-select: none;
white-space: nowrap;
}
.cg-output-tab:hover {
color: #444;
}
.cg-output-tab.active {
color: var(--md-primary-fg-color);
border-bottom-color: var(--md-primary-fg-color);
}
.cg-btn-copy {
margin-left: auto;
background: none;
color: #777;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 9px 10px;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
}
.cg-btn-copy:hover {
color: #333;
}
.cg-output-wrap {
flex: 1;
overflow: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.cg-output-wrap pre {
margin: 0;
padding: 8px 10px;
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
border-radius: 6px;
overflow-x: auto;
font-size: 0.76rem;
line-height: 1.5;
flex: 1;
white-space: pre;
}
.cg-empty-msg {
color: #888;
font-style: italic;
}
.cg-warning {
padding: 6px 10px;
margin-top: 8px;
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
border-radius: 4px;
font-size: 0.76rem;
}
/* Form fields */
.cg-field {
margin-bottom: 0;
padding: 8px 12px;
}
.cg-field:nth-child(odd) {
background: #f8f8f8;
}
.cg-field:nth-child(even) {
background: #fff;
}
.cg-field > label {
display: block;
font-weight: 500;
margin-bottom: 4px;
font-size: 0.78rem;
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.78rem;
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: 10px;
}
.cg-checkbox input[type="checkbox"] {
accent-color: var(--md-primary-fg-color);
}
.cg-checkbox label {
font-weight: 500;
font-size: 0.78rem;
margin: 0;
cursor: pointer;
}
.cg-radio-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.cg-radio-group label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
font-size: 0.78rem;
cursor: pointer;
}
.cg-radio-group input[type="radio"] {
accent-color: var(--md-primary-fg-color);
}
/* Inline field: label + control side by side */
.cg-inline-field {
display: flex;
align-items: center;
gap: 12px;
}
.cg-inline-field > label {
margin-bottom: 0;
width: 60%;
flex-shrink: 0;
}
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
width: 50%;
}
.cg-inline-field > input[type="text"],
.cg-inline-field > select {
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.75rem;
font-family: inherit;
box-sizing: border-box;
line-height: 1.4;
background: #fff;
}
.cg-inline-field > input[type="text"]:focus,
.cg-inline-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-email-in-section {
margin-top: 20px;
}
.cg-pg-label {
font-size: 0.75rem;
color: #888;
font-style: italic;
}
/* Button group toggle */
.cg-btn-group {
display: flex;
flex-shrink: 0;
}
.cg-btn-group label {
cursor: pointer;
margin: 0;
}
.cg-btn-group input[type="radio"] {
display: none;
}
.cg-btn-group span {
display: block;
padding: 4px 14px;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.4;
border: 1px solid #ccc;
color: #555;
background: #fff;
transition: background 0.15s, color 0.15s, border-color 0.15s;
user-select: none;
}
.cg-btn-group label:first-child span {
border-radius: 4px 0 0 4px;
}
.cg-btn-group label:last-child span {
border-radius: 0 4px 4px 0;
}
.cg-btn-group label + label span {
margin-left: -1px;
}
.cg-btn-group input[type="radio"]:checked + span {
background: var(--md-primary-fg-color);
color: #fff;
border-color: var(--md-primary-fg-color);
position: relative;
z-index: 1;
}
.cg-feature-grid {
display: flex;
flex-direction: column;
gap: 5px;
}
.cg-feature-row {
display: flex;
align-items: center;
gap: 8px;
}
.cg-feature-grid label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
cursor: pointer;
}
.cg-feature-grid input[type="checkbox"] {
accent-color: var(--md-primary-fg-color);
}
.cg-btn-configure {
background: none;
border: 1px solid var(--md-primary-fg-color);
border-radius: 10px;
color: var(--md-primary-fg-color);
font-size: 0.68rem;
font-family: inherit;
padding: 1px 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.cg-btn-configure:hover {
background: var(--md-primary-fg-color);
color: #fff;
}
/* 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.75rem;
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-repeatable-row input:disabled,
.cg-repeatable-row select:disabled {
background: #eee;
color: #999;
cursor: not-allowed;
}
.cg-btn-remove {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
padding: 5px 8px;
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.75rem;
color: #777;
margin-top: 2px;
}
.cg-btn-add:hover {
border-color: var(--md-primary-fg-color);
color: var(--md-primary-fg-color);
}
/* Dark mode */
body[data-md-color-scheme="slate"] .cg-modal-dialog {
background: #1e1e2e;
}
body[data-md-color-scheme="slate"] .cg-modal-header {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-modal-title {
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-modal-desc {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-modal-reset {
border-color: #555;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-modal-reset:hover {
border-color: #888;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-modal-close {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-modal-close:hover {
color: #ddd;
}
body[data-md-color-scheme="slate"] #cg-left {
border-right-color: #444;
}
body[data-md-color-scheme="slate"] .cg-nav {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-nav-tab {
color: #888;
}
body[data-md-color-scheme="slate"] .cg-nav-tab:hover {
color: #bbb;
}
body[data-md-color-scheme="slate"] .cg-output-tabs {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-output-tab {
color: #888;
}
body[data-md-color-scheme="slate"] .cg-output-tab:hover {
color: #bbb;
}
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-output-wrap pre {
background: #161620;
color: #ddd;
border-color: #444;
}
body[data-md-color-scheme="slate"] .cg-field:nth-child(odd) {
background: #232334;
}
body[data-md-color-scheme="slate"] .cg-field:nth-child(even) {
background: #1e1e2e;
}
body[data-md-color-scheme="slate"] .cg-panel-desc {
color: #777;
}
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: #2a2a3a;
border-color: #555;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-btn-group span {
background: #2a2a3a;
border-color: #555;
color: #aaa;
}
body[data-md-color-scheme="slate"] .cg-btn-group input[type="radio"]:checked + span {
background: var(--md-primary-fg-color);
color: #fff;
border-color: var(--md-primary-fg-color);
}
body[data-md-color-scheme="slate"] .cg-checkbox label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-radio-group label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-feature-grid label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-repeatable-row input,
body[data-md-color-scheme="slate"] .cg-repeatable-row select {
background: #2a2a3a;
border-color: #555;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-repeatable-row input:disabled,
body[data-md-color-scheme="slate"] .cg-repeatable-row select:disabled {
background: #1a1a28;
color: #666;
}
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-warning {
background: #3a2e00;
color: #ffc107;
border-color: #665200;
}
/* Mobile toggle bar (hidden on desktop) */
.cg-mobile-toggle {
display: none;
}
/* Responsive */
@media (max-width: 900px) {
.cg-modal-dialog {
inset: 0;
border-radius: 0;
}
.cg-modal-header {
padding: 8px 16px;
}
.cg-modal-title {
font-size: 0.85rem;
}
.cg-modal-desc {
display: none;
}
.cg-modal-body {
flex-direction: column;
}
.cg-mobile-toggle {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid #ddd;
}
.cg-mobile-toggle-btn {
flex: 1;
padding: 8px 0;
border: none;
background: #f5f5f5;
font-size: 0.78rem;
font-weight: 500;
font-family: inherit;
color: #777;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.cg-mobile-toggle-btn.active {
background: #fff;
color: var(--md-primary-fg-color);
box-shadow: inset 0 -2px 0 var(--md-primary-fg-color);
}
#cg-left {
border-right: none;
flex: 1;
min-height: 0;
}
#cg-right {
flex: 1;
display: none;
min-height: 0;
}
#cg-right.cg-mobile-active {
display: flex;
}
#cg-left.cg-mobile-hidden {
display: none;
}
.cg-nav {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
}
.cg-inline-field {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.cg-inline-field > label {
width: 100%;
}
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
width: 100%;
}
}
/* Dark mode mobile toggle */
body[data-md-color-scheme="slate"] .cg-mobile-toggle {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn {
background: #2a2a3a;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn.active {
background: #1e1e2e;
color: var(--md-primary-fg-color);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

+1220
View File
File diff suppressed because it is too large Load Diff
+1357
View File
File diff suppressed because it is too large Load Diff
+24 -24
View File
@@ -4,7 +4,7 @@ go 1.25.0
require ( require (
cloud.google.com/go/firestore v1.21.0 // indirect cloud.google.com/go/firestore v1.21.0 // indirect
cloud.google.com/go/storage v1.60.0 // indirect cloud.google.com/go/storage v1.61.3 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.18.0 github.com/emersion/go-smtp v0.18.0
@@ -14,12 +14,12 @@ require (
github.com/olebedev/when v1.1.0 github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7 github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.49.0
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.19.0 golang.org/x/sync v0.20.0
golang.org/x/term v0.40.0 golang.org/x/term v0.41.0
golang.org/x/time v0.14.0 golang.org/x/time v0.15.0
google.golang.org/api v0.269.0 google.golang.org/api v0.271.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
@@ -34,14 +34,14 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/stripe/stripe-go/v74 v74.30.0 github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/sys v0.41.0 golang.org/x/sys v0.42.0
golang.org/x/text v0.34.0 golang.org/x/text v0.35.0
) )
require ( require (
cel.dev/expr v0.25.1 // indirect cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // 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/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/iam v1.5.3 // indirect
@@ -70,7 +70,7 @@ require (
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/googleapis/gax-go/v2 v2.18.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -86,20 +86,20 @@ require (
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.41.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/sdk v1.41.0 // indirect go.opentelemetry.io/otel/sdk v1.42.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.52.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/grpc v1.79.2 // indirect google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
+50 -50
View File
@@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 h1:PJt3KrySfZkKdcEV2wlyNkfAPbMZGjtnv5oLrT4tWPg=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568/go.mod h1:/Tt0rLCp4FHXEBtdyYqvIZPcJzbpJ/fmqtgIaXseDK4=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= 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/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
@@ -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/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 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8= cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0= cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= 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= firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
@@ -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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= 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/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.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -165,36 +165,36 @@ 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= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.41.0 h1:MBzEwqhroF0JK0DpTVYWDxsenxm6L4PqOEfA90uZ5AA= go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
go.opentelemetry.io/contrib/detectors/gcp v1.41.0/go.mod h1:5pSDD0v0t2HqUmPC5cBBc+nLQO4dLYWnzBNheXLBLgs= go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
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.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0/go.mod h1:pdhNtM9C4H5fRdrnwO7NjxzQWhKSSxCHk/KluVqDVC0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -209,10 +209,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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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= 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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -220,8 +220,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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -234,8 +234,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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -245,8 +245,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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -258,10 +258,10 @@ 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -272,16 +272,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= 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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY=
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= 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/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 h1:RxhCsti413yL0IjU9dVvuTbCISo8gs3RW1jPMStck+4= google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c h1:ZhFDeBMmFc/4g8/GwxnJ4rzB3O4GwQVNr+8Mh7Y5z4g=
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171/go.mod h1:uhvzakVEqAuXU3TC2JCsxIRe5f77l+JySE3EqPoMyqM= google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c/go.mod h1:hf4r/rBuzaTkLUWRO03771Xvcs6P5hwdQK3UUEJjqo0=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= 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/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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+25 -23
View File
@@ -50,14 +50,14 @@ type queries struct {
// Cache stores published messages // Cache stores published messages
type Cache struct { type Cache struct {
db *sql.DB db *db.DB
queue *util.BatchingQueue[*model.Message] queue *util.BatchingQueue[*model.Message]
nop bool nop bool
mu *sync.Mutex // nil for PostgreSQL (concurrent writes supported), set for SQLite (single writer) mu *sync.Mutex // nil for PostgreSQL (concurrent writes supported), set for SQLite (single writer)
queries queries queries queries
} }
func newCache(db *sql.DB, queries queries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *Cache { func newCache(db *db.DB, queries queries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *Cache {
var queue *util.BatchingQueue[*model.Message] var queue *util.BatchingQueue[*model.Message]
if batchSize > 0 || batchTimeout > 0 { if batchSize > 0 || batchTimeout > 0 {
queue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout) queue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout)
@@ -125,16 +125,16 @@ func (c *Cache) addMessages(ms []*model.Message) error {
return model.ErrUnexpectedMessageType return model.ErrUnexpectedMessageType
} }
published := m.Time <= time.Now().Unix() published := m.Time <= time.Now().Unix()
tags := strings.Join(m.Tags, ",") tags := util.SanitizeUTF8(strings.Join(m.Tags, ","))
var attachmentName, attachmentType, attachmentURL string var attachmentName, attachmentType, attachmentURL string
var attachmentSize, attachmentExpires int64 var attachmentSize, attachmentExpires int64
var attachmentDeleted bool var attachmentDeleted bool
if m.Attachment != nil { if m.Attachment != nil {
attachmentName = m.Attachment.Name attachmentName = util.SanitizeUTF8(m.Attachment.Name)
attachmentType = m.Attachment.Type attachmentType = util.SanitizeUTF8(m.Attachment.Type)
attachmentSize = m.Attachment.Size attachmentSize = m.Attachment.Size
attachmentExpires = m.Attachment.Expires attachmentExpires = m.Attachment.Expires
attachmentURL = m.Attachment.URL attachmentURL = util.SanitizeUTF8(m.Attachment.URL)
} }
var actionsStr string var actionsStr string
if len(m.Actions) > 0 { if len(m.Actions) > 0 {
@@ -154,13 +154,13 @@ func (c *Cache) addMessages(ms []*model.Message) error {
m.Time, m.Time,
m.Event, m.Event,
m.Expires, m.Expires,
m.Topic, util.SanitizeUTF8(m.Topic),
m.Message, util.SanitizeUTF8(m.Message),
m.Title, util.SanitizeUTF8(m.Title),
m.Priority, m.Priority,
tags, tags,
m.Click, util.SanitizeUTF8(m.Click),
m.Icon, util.SanitizeUTF8(m.Icon),
actionsStr, actionsStr,
attachmentName, attachmentName,
attachmentType, attachmentType,
@@ -170,7 +170,7 @@ func (c *Cache) addMessages(ms []*model.Message) error {
attachmentDeleted, // Always zero attachmentDeleted, // Always zero
sender, sender,
m.User, m.User,
m.ContentType, util.SanitizeUTF8(m.ContentType),
m.Encoding, m.Encoding,
published, published,
) )
@@ -201,10 +201,11 @@ func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool)
func (c *Cache) 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 rows *sql.Rows
var err error var err error
rdb := c.db.ReadOnly()
if scheduled { if scheduled {
rows, err = c.db.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix()) rows, err = rdb.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix())
} else { } else {
rows, err = c.db.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix()) rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -215,10 +216,11 @@ func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, schedul
func (c *Cache) 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 rows *sql.Rows
var err error var err error
rdb := c.db.ReadOnly()
if scheduled { if scheduled {
rows, err = c.db.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID()) rows, err = rdb.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID())
} else { } else {
rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, since.ID()) rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID())
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -227,7 +229,7 @@ func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled
} }
func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) { func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
rows, err := c.db.Query(c.queries.selectMessagesLatest, topic) rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesLatest, topic)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -266,7 +268,7 @@ func (c *Cache) MessagesExpired() ([]string, error) {
// Message returns the message with the given ID, or ErrMessageNotFound if not found // Message returns the message with the given ID, or ErrMessageNotFound if not found
func (c *Cache) Message(id string) (*model.Message, error) { func (c *Cache) Message(id string) (*model.Message, error) {
rows, err := c.db.Query(c.queries.selectMessagesByID, id) rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesByID, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -295,7 +297,7 @@ func (c *Cache) MarkPublished(m *model.Message) error {
// MessagesCount returns the total number of messages in the cache // MessagesCount returns the total number of messages in the cache
func (c *Cache) MessagesCount() (int, error) { func (c *Cache) MessagesCount() (int, error) {
rows, err := c.db.Query(c.queries.selectMessagesCount) rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesCount)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -312,7 +314,7 @@ func (c *Cache) MessagesCount() (int, error) {
// Topics returns a list of all topics with messages in the cache // Topics returns a list of all topics with messages in the cache
func (c *Cache) Topics() ([]string, error) { func (c *Cache) Topics() ([]string, error) {
rows, err := c.db.Query(c.queries.selectTopics) rows, err := c.db.ReadOnly().Query(c.queries.selectTopics)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -426,7 +428,7 @@ func (c *Cache) MarkAttachmentsDeleted(ids ...string) error {
// AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender // AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender
func (c *Cache) AttachmentBytesUsedBySender(sender string) (int64, error) { func (c *Cache) AttachmentBytesUsedBySender(sender string) (int64, error) {
rows, err := c.db.Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix()) rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix())
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -435,7 +437,7 @@ func (c *Cache) AttachmentBytesUsedBySender(sender string) (int64, error) {
// AttachmentBytesUsedByUser returns the total size of active attachments for the given user // AttachmentBytesUsedByUser returns the total size of active attachments for the given user
func (c *Cache) AttachmentBytesUsedByUser(userID string) (int64, error) { func (c *Cache) AttachmentBytesUsedByUser(userID string) (int64, error) {
rows, err := c.db.Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix()) rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix())
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -466,7 +468,7 @@ func (c *Cache) UpdateStats(messages int64) error {
// Stats returns the total message count statistic // Stats returns the total message count statistic
func (c *Cache) Stats() (messages int64, err error) { func (c *Cache) Stats() (messages int64, err error) {
rows, err := c.db.Query(c.queries.selectStats) rows, err := c.db.ReadOnly().Query(c.queries.selectStats)
if err != nil { if err != nil {
return 0, err return 0, err
} }
+5 -4
View File
@@ -1,8 +1,9 @@
package message package message
import ( import (
"database/sql"
"time" "time"
"heckel.io/ntfy/v2/db"
) )
// PostgreSQL runtime query constants // PostgreSQL runtime query constants
@@ -102,9 +103,9 @@ var postgresQueries = queries{
} }
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. // 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) { func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
if err := setupPostgres(db); err != nil { if err := setupPostgres(d.Primary()); err != nil {
return nil, err return nil, err
} }
return newCache(db, postgresQueries, nil, batchSize, batchTimeout, false), nil return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil
} }
+4 -3
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -110,14 +111,14 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration
if !util.FileExists(parentDir) { if !util.FileExists(parentDir) {
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir) return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
} }
db, err := sql.Open("sqlite3", filename) d, err := sql.Open("sqlite3", filename)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := setupSQLite(db, startupQueries, cacheDuration); err != nil { if err := setupSQLite(d, startupQueries, cacheDuration); err != nil {
return nil, err return nil, err
} }
return newCache(db, sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil return newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil
} }
// NewMemStore creates an in-memory cache // NewMemStore creates an in-memory cache
+138
View File
@@ -827,3 +827,141 @@ func TestStore_MessageFieldRoundTrip(t *testing.T) {
require.Equal(t, `{"key":"value"}`, retrieved.Actions[1].Body) require.Equal(t, `{"key":"value"}`, retrieved.Actions[1].Body)
}) })
} }
func TestStore_AddMessage_InvalidUTF8(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// 0xc9 0x43: Latin-1 "ÉC" — 0xc9 starts a 2-byte UTF-8 sequence but 0x43 ('C') is not a continuation byte
m := model.NewDefaultMessage("mytopic", "\xc9Cas du serveur")
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "\uFFFDCas du serveur", messages[0].Message)
// 0xae: Latin-1 "®" — isolated byte above 0x7F, not a valid UTF-8 start for single byte
m2 := model.NewDefaultMessage("mytopic", "Product\xae Pro")
require.Nil(t, s.AddMessage(m2))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "Product\uFFFD Pro", messages[1].Message)
// 0xe8 0x6d 0x65: Latin-1 "ème" — 0xe8 starts a 3-byte UTF-8 sequence but 0x6d ('m') is not a continuation byte
m3 := model.NewDefaultMessage("mytopic", "probl\xe8me critique")
require.Nil(t, s.AddMessage(m3))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "probl\uFFFDme critique", messages[2].Message)
// 0xb2: Latin-1 "²" — isolated byte in 0x80-0xBF range (UTF-8 continuation byte without lead)
m4 := model.NewDefaultMessage("mytopic", "CO\xb2 level high")
require.Nil(t, s.AddMessage(m4))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "CO\uFFFD level high", messages[3].Message)
// 0xe9 0x6d 0x61: Latin-1 "éma" — 0xe9 starts a 3-byte UTF-8 sequence but 0x6d ('m') is not a continuation byte
m5 := model.NewDefaultMessage("mytopic", "th\xe9matique")
require.Nil(t, s.AddMessage(m5))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "th\uFFFDmatique", messages[4].Message)
// 0xed 0x64 0x65: Latin-1 "íde" — 0xed starts a 3-byte UTF-8 sequence but 0x64 ('d') is not a continuation byte
m6 := model.NewDefaultMessage("mytopic", "vid\xed\x64eo surveillance")
require.Nil(t, s.AddMessage(m6))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "vid\uFFFDdeo surveillance", messages[5].Message)
// 0xf3 0x6e 0x3a 0x20: Latin-1 "ón: " — 0xf3 starts a 4-byte UTF-8 sequence but 0x6e ('n') is not a continuation byte
m7 := model.NewDefaultMessage("mytopic", "notificaci\xf3n: alerta")
require.Nil(t, s.AddMessage(m7))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "notificaci\uFFFDn: alerta", messages[6].Message)
// 0xb7: Latin-1 "·" — isolated continuation byte
m8 := model.NewDefaultMessage("mytopic", "item\xb7value")
require.Nil(t, s.AddMessage(m8))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "item\uFFFDvalue", messages[7].Message)
// 0xa8: Latin-1 "¨" — isolated continuation byte
m9 := model.NewDefaultMessage("mytopic", "na\xa8ve")
require.Nil(t, s.AddMessage(m9))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "na\uFFFDve", messages[8].Message)
// 0xdf 0x64: Latin-1 "ßd" — 0xdf starts a 2-byte UTF-8 sequence but 0x64 ('d') is not a continuation byte
m10 := model.NewDefaultMessage("mytopic", "gro\xdf\x64ruck")
require.Nil(t, s.AddMessage(m10))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "gro\uFFFDdruck", messages[9].Message)
// 0xe4 0x67 0x74: Latin-1 "ägt" — 0xe4 starts a 3-byte UTF-8 sequence but 0x67 ('g') is not a continuation byte
m11 := model.NewDefaultMessage("mytopic", "tr\xe4gt Last")
require.Nil(t, s.AddMessage(m11))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "tr\uFFFDgt Last", messages[10].Message)
// 0xe9 0x65 0x20: Latin-1 "ée " — 0xe9 starts a 3-byte UTF-8 sequence but 0x65 ('e') is not a continuation byte
m12 := model.NewDefaultMessage("mytopic", "journ\xe9\x65 termin\xe9\x65")
require.Nil(t, s.AddMessage(m12))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "journ\uFFFDe termin\uFFFDe", messages[11].Message)
})
}
func TestStore_AddMessage_NullByte(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// 0x00: NUL byte — valid UTF-8 but rejected by PostgreSQL
m := model.NewDefaultMessage("mytopic", "hello\x00world")
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "helloworld", messages[0].Message)
})
}
func TestStore_AddMessage_InvalidUTF8InTitleAndTags(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Invalid UTF-8 can arrive via HTTP headers (Title, Tags) which bypass body validation
m := model.NewDefaultMessage("mytopic", "valid message")
m.Title = "\xc9clipse du syst\xe8me"
m.Tags = []string{"probl\xe8me", "syst\xe9me"}
m.Click = "https://example.com/\xae"
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "\uFFFDclipse du syst\uFFFDme", messages[0].Title)
require.Equal(t, "probl\uFFFDme", messages[0].Tags[0])
require.Equal(t, "syst\uFFFDme", messages[0].Tags[1])
require.Equal(t, "https://example.com/\uFFFD", messages[0].Click)
})
}
func TestStore_AddMessage_InvalidUTF8BatchDoesNotDropValidMessages(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Previously, a single invalid message would roll back the entire batch transaction.
// Sanitization ensures all messages in a batch are written successfully.
msgs := []*model.Message{
model.NewDefaultMessage("mytopic", "valid message 1"),
model.NewDefaultMessage("mytopic", "notificaci\xf3n: alerta"),
model.NewDefaultMessage("mytopic", "valid message 3"),
}
require.Nil(t, s.AddMessages(msgs))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 3, len(messages))
})
}
+3
View File
@@ -42,8 +42,11 @@ extra:
link: https://github.com/binwiederhier link: https://github.com/binwiederhier
extra_javascript: extra_javascript:
- static/js/extra.js - static/js/extra.js
- static/js/bcrypt.js
- static/js/config-generator.js
extra_css: extra_css:
- static/css/extra.css - static/css/extra.css
- static/css/config-generator.css
markdown_extensions: markdown_extensions:
- admonition - admonition
+20
View File
@@ -70,6 +70,26 @@ func (m *Message) Context() log.Context {
return fields return fields
} }
// SanitizeUTF8 replaces invalid UTF-8 sequences and strips NUL bytes from all user-supplied
// string fields. This is called early in the publish path so that all downstream consumers
// (Firebase, WebPush, SMTP, cache) receive clean UTF-8 strings.
func (m *Message) SanitizeUTF8() {
m.Topic = util.SanitizeUTF8(m.Topic)
m.Message = util.SanitizeUTF8(m.Message)
m.Title = util.SanitizeUTF8(m.Title)
m.Click = util.SanitizeUTF8(m.Click)
m.Icon = util.SanitizeUTF8(m.Icon)
m.ContentType = util.SanitizeUTF8(m.ContentType)
for i, tag := range m.Tags {
m.Tags[i] = util.SanitizeUTF8(tag)
}
if m.Attachment != nil {
m.Attachment.Name = util.SanitizeUTF8(m.Attachment.Name)
m.Attachment.Type = util.SanitizeUTF8(m.Attachment.Type)
m.Attachment.URL = util.SanitizeUTF8(m.Attachment.URL)
}
}
// ForJSON returns a copy of the message suitable for JSON output. // ForJSON returns a copy of the message suitable for JSON output.
// It clears the SequenceID if it equals the ID to reduce redundancy. // It clears the SequenceID if it equals the ID to reduce redundancy.
func (m *Message) ForJSON() *Message { func (m *Message) ForJSON() *Message {
+2 -1
View File
@@ -95,7 +95,8 @@ type Config struct {
ListenUnixMode fs.FileMode ListenUnixMode fs.FileMode
KeyFile string KeyFile string
CertFile string CertFile string
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy") DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
DatabaseReplicaURLs []string // PostgreSQL read replica connection strings
FirebaseKeyFile string FirebaseKeyFile string
CacheFile string CacheFile string
CacheDuration time.Duration CacheDuration time.Duration
+1 -1
View File
@@ -36,7 +36,7 @@ const (
) )
var ( var (
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage} normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge}
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge} rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
) )
+20 -6
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"database/sql"
"embed" "embed"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -33,6 +32,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/message"
@@ -47,7 +47,7 @@ import (
// Server is the main server, providing the UI and API for ntfy // Server is the main server, providing the UI and API for ntfy
type Server struct { type Server struct {
config *Config config *Config
db *sql.DB // Shared PostgreSQL connection pool, nil when using SQLite db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite
httpServer *http.Server httpServer *http.Server
httpsServer *http.Server httpsServer *http.Server
httpMetricsServer *http.Server httpMetricsServer *http.Server
@@ -179,13 +179,26 @@ func New(conf *Config) (*Server, error) {
stripe = newStripeAPI() stripe = newStripeAPI()
} }
// Open shared PostgreSQL connection pool if configured // Open shared PostgreSQL connection pool if configured
var pool *sql.DB var pool *db.DB
if conf.DatabaseURL != "" { if conf.DatabaseURL != "" {
var err error primary, err := pg.Open(conf.DatabaseURL)
pool, err = pg.Open(conf.DatabaseURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var replicas []*db.Host
for _, replicaURL := range conf.DatabaseReplicaURLs {
r, err := pg.OpenReplica(replicaURL)
if err != nil {
// Close already-opened replicas before returning
for _, opened := range replicas {
opened.DB.Close()
}
primary.DB.Close()
return nil, fmt.Errorf("failed to open database replica: %w", err)
}
replicas = append(replicas, r)
}
pool = db.New(primary, replicas)
} }
messageCache, err := createMessageCache(conf, pool) messageCache, err := createMessageCache(conf, pool)
if err != nil { if err != nil {
@@ -277,7 +290,7 @@ func New(conf *Config) (*Server, error) {
return s, nil return s, nil
} }
func createMessageCache(conf *Config, pool *sql.DB) (*message.Cache, error) { func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) {
if conf.CacheDuration == 0 { if conf.CacheDuration == 0 {
return message.NewNopStore() return message.NewNopStore()
} else if pool != nil { } else if pool != nil {
@@ -867,6 +880,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
if m.Message == "" { if m.Message == "" {
m.Message = emptyMessageBody m.Message = emptyMessageBody
} }
m.SanitizeUTF8()
delayed := m.Time > time.Now().Unix() delayed := m.Time > time.Now().Unix()
ev := logvrm(v, r, m). ev := logvrm(v, r, m).
Tag(tagPublish). Tag(tagPublish).
+18 -30
View File
@@ -3,14 +3,15 @@ package server
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"net/http" "net/http"
"net/netip" "net/netip"
"strings" "strings"
"time" "time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
) )
const ( const (
@@ -455,21 +456,8 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
return errHTTPUnauthorized return errHTTPUnauthorized
} else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil { } else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {
return errHTTPConflictTopicReserved return errHTTPConflictTopicReserved
} else if u.IsUser() {
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
if err != nil {
return err
}
if !hasReservation {
reservations, err := s.userManager.ReservationsCount(u.Name)
if err != nil {
return err
} else if reservations >= u.Tier.ReservationLimit {
return errHTTPTooManyRequestsLimitReservations
}
}
} }
// Actually add the reservation // Actually add the reservation (with limit check inside the transaction to avoid races)
logvr(v, r). logvr(v, r).
Tag(tagAccount). Tag(tagAccount).
Fields(log.Context{ Fields(log.Context{
@@ -477,7 +465,14 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
"everyone": everyone.String(), "everyone": everyone.String(),
}). }).
Debug("Adding topic reservation") Debug("Adding topic reservation")
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil { var limit int64
if u.IsUser() && u.Tier != nil {
limit = u.Tier.ReservationLimit
}
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone, limit); err != nil {
if errors.Is(err, user.ErrTooManyReservations) {
return errHTTPTooManyRequestsLimitReservations
}
return err return err
} }
// Kill existing subscribers // Kill existing subscribers
@@ -530,22 +525,15 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
// and marks associated messages for the topics as deleted. This also eventually deletes attachments. // and marks associated messages for the topics as deleted. This also eventually deletes attachments.
// The process relies on the manager to perform the actual deletions (see runManager). // The process relies on the manager to perform the actual deletions (see runManager).
func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error { func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error {
reservations, err := s.userManager.Reservations(u.Name) removedTopics, err := s.userManager.RemoveExcessReservations(u.Name, reservationsLimit)
if err != nil { if err != nil {
return err return err
} else if int64(len(reservations)) <= reservationsLimit { } else if len(removedTopics) == 0 {
logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove") logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove")
return nil return nil
} }
topics := make([]string, 0) logvr(v, r).Tag(tagAccount).Info("Removed excess topic reservations, now removing messages for topics %s", strings.Join(removedTopics, ", "))
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- { if err := s.messageCache.ExpireMessages(removedTopics...); err != nil {
topics = append(topics, reservations[i].Topic)
}
logvr(v, r).Tag(tagAccount).Info("Removing excess reservations for topics %s", strings.Join(topics, ", "))
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
return err
}
if err := s.messageCache.ExpireMessages(topics...); err != nil {
return err return err
} }
go s.pruneMessages() go s.pruneMessages()
+1 -1
View File
@@ -503,7 +503,7 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
})) }))
require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser, false)) require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro")) require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro"))
require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll)) require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll, 0))
require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser, false)) require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro")) require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro"))
+3 -3
View File
@@ -478,8 +478,8 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro")) 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", "atopic", user.PermissionDenyAll, 0))
require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll)) require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll, 0))
// Add billing details // Add billing details
u, err := s.userManager.User("phil") u, err := s.userManager.User("phil")
@@ -589,7 +589,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro")) 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", "atopic", user.PermissionDenyAll, 0))
// Add billing details // Add billing details
u, err := s.userManager.User("phil") u, err := s.userManager.User("phil")
+85
View File
@@ -4441,3 +4441,88 @@ func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) {
} }
}) })
} }
func TestServer_Publish_InvalidUTF8InBody(t *testing.T) {
// All byte sequences from production logs, sent as message body
tests := []struct {
name string
body string
message string
}{
{"0xc9_0x43", "\xc9Cas du serveur", "\uFFFDCas du serveur"}, // Latin-1 "ÉC"
{"0xae", "Product\xae Pro", "Product\uFFFD Pro"}, // Latin-1 "®"
{"0xe8_0x6d_0x65", "probl\xe8me critique", "probl\uFFFDme critique"}, // Latin-1 "ème"
{"0xb2", "CO\xb2 level high", "CO\uFFFD level high"}, // Latin-1 "²"
{"0xe9_0x6d_0x61", "th\xe9matique", "th\uFFFDmatique"}, // Latin-1 "éma"
{"0xed_0x64_0x65", "vid\xed\x64eo surveillance", "vid\uFFFDdeo surveillance"}, // Latin-1 "íde"
{"0xf3_0x6e_0x3a_0x20", "notificaci\xf3n: alerta", "notificaci\uFFFDn: alerta"}, // Latin-1 "ón: "
{"0xb7", "item\xb7value", "item\uFFFDvalue"}, // Latin-1 "·"
{"0xa8", "na\xa8ve", "na\uFFFDve"}, // Latin-1 "¨"
{"0x00", "hello\x00world", "helloworld"}, // NUL byte
{"0xdf_0x64", "gro\xdf\x64ruck", "gro\uFFFDdruck"}, // Latin-1 "ßd"
{"0xe4_0x67_0x74", "tr\xe4gt Last", "tr\uFFFDgt Last"}, // Latin-1 "ägt"
{"0xe9_0x65_0x20", "journ\xe9\x65 termin\xe9\x65", "journ\uFFFDe termin\uFFFDe"}, // Latin-1 "ée"
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
// Publish via x-message header (the most common path for invalid UTF-8 from HTTP headers)
response := request(t, s, "PUT", "/mytopic", "", map[string]string{
"X-Message": tc.body,
})
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, tc.message, msg.Message)
// Verify it was stored in the cache correctly
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
msg = toMessage(t, response.Body.String())
require.Equal(t, tc.message, msg.Message)
})
}
}
func TestServer_Publish_InvalidUTF8InTitle(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
response := request(t, s, "PUT", "/mytopic", "valid body", map[string]string{
"Title": "\xc9clipse du syst\xe8me",
})
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "\uFFFDclipse du syst\uFFFDme", msg.Title)
require.Equal(t, "valid body", msg.Message)
}
func TestServer_Publish_InvalidUTF8InTags(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
response := request(t, s, "PUT", "/mytopic", "valid body", map[string]string{
"Tags": "probl\xe8me,syst\xe9me",
})
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "probl\uFFFDme", msg.Tags[0])
require.Equal(t, "syst\uFFFDme", msg.Tags[1])
}
func TestServer_Publish_InvalidUTF8WithFirebase(t *testing.T) {
// Verify that sanitization happens before Firebase dispatch, so Firebase
// receives clean UTF-8 strings rather than invalid byte sequences
sender := newTestFirebaseSender(10)
s := newTestServer(t, newTestConfig(t, ""))
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
response := request(t, s, "PUT", "/mytopic", "", map[string]string{
"X-Message": "notificaci\xf3n: alerta",
"Title": "\xc9clipse",
"Tags": "probl\xe8me",
})
require.Equal(t, 200, response.Code)
time.Sleep(100 * time.Millisecond) // Firebase publishing happens asynchronously
require.Equal(t, 1, len(sender.Messages()))
require.Equal(t, "notificaci\uFFFDn: alerta", sender.Messages()[0].Data["message"])
require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"])
require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"])
}
+13 -2
View File
@@ -3,7 +3,7 @@ package test
import ( import (
"fmt" "fmt"
"heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/server"
"math/rand" "net"
"net/http" "net/http"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -16,7 +16,7 @@ func StartServer(t *testing.T) (*server.Server, int) {
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up // StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) { func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
port := 10000 + rand.Intn(30000) port := findAvailablePort(t)
conf.ListenHTTP = fmt.Sprintf(":%d", port) conf.ListenHTTP = fmt.Sprintf(":%d", port)
conf.AttachmentCacheDir = t.TempDir() conf.AttachmentCacheDir = t.TempDir()
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
@@ -33,6 +33,17 @@ func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, i
return s, port return s, port
} }
// findAvailablePort asks the OS for a free port by binding to :0
func findAvailablePort(t *testing.T) int {
listener, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
return port
}
// StopServer stops the test server and waits for the port to be down // StopServer stops the test server and waits for the port to be down
func StopServer(t *testing.T, s *server.Server, port int) { func StopServer(t *testing.T, s *server.Server, port int) {
s.Stop() s.Stop()
+95 -10
View File
@@ -65,12 +65,12 @@ const (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value BIGINT value BIGINT
); );
INSERT INTO message_stats (key, value) VALUES ('messages', 0); INSERT INTO message_stats (key, value) VALUES ('messages', 0) ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS schema_version ( CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY, store TEXT PRIMARY KEY,
version INT NOT NULL version INT NOT NULL
); );
INSERT INTO schema_version (store, version) VALUES ('message', 14); INSERT INTO schema_version (store, version) VALUES ('message', 14) ON CONFLICT (store) DO NOTHING;
` `
// Initial PostgreSQL schema for user store (from user/manager_postgres_schema.go) // Initial PostgreSQL schema for user store (from user/manager_postgres_schema.go)
@@ -146,7 +146,7 @@ const (
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created) INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT) VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO schema_version (store, version) VALUES ('user', 6); INSERT INTO schema_version (store, version) VALUES ('user', 6) ON CONFLICT (store) DO NOTHING;
` `
// Initial PostgreSQL schema for web push store (from webpush/store_postgres.go) // Initial PostgreSQL schema for web push store (from webpush/store_postgres.go)
@@ -174,7 +174,7 @@ const (
store TEXT PRIMARY KEY, store TEXT PRIMARY KEY,
version INT NOT NULL version INT NOT NULL
); );
INSERT INTO schema_version (store, version) VALUES ('webpush', 1); INSERT INTO schema_version (store, version) VALUES ('webpush', 1) ON CONFLICT (store) DO NOTHING;
` `
) )
@@ -185,6 +185,7 @@ var flags = []cli.Flag{
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file"}, Usage: "SQLite user/auth database 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"}), 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"}, &cli.BoolFlag{Name: "create-schema", Usage: "create initial PostgreSQL schema before importing"},
&cli.BoolFlag{Name: "pre-import", Usage: "pre-import messages while ntfy is still running (only imports messages)"},
} }
func main() { func main() {
@@ -207,10 +208,17 @@ func execImport(c *cli.Context) error {
cacheFile := c.String("cache-file") cacheFile := c.String("cache-file")
authFile := c.String("auth-file") authFile := c.String("auth-file")
webPushFile := c.String("web-push-file") webPushFile := c.String("web-push-file")
preImport := c.Bool("pre-import")
if databaseURL == "" { if databaseURL == "" {
return fmt.Errorf("database-url must be set (via --database-url or config file)") return fmt.Errorf("database-url must be set (via --database-url or config file)")
} }
if preImport {
if cacheFile == "" {
return fmt.Errorf("--cache-file must be set when using --pre-import")
}
return execPreImport(c, databaseURL, cacheFile)
}
if cacheFile == "" && authFile == "" && webPushFile == "" { if cacheFile == "" && authFile == "" && webPushFile == "" {
return fmt.Errorf("at least one of --cache-file, --auth-file, or --web-push-file must be set") return fmt.Errorf("at least one of --cache-file, --auth-file, or --web-push-file must be set")
} }
@@ -236,10 +244,11 @@ func execImport(c *cli.Context) error {
} }
fmt.Println() fmt.Println()
pgDB, err := pg.Open(databaseURL) pgHost, err := pg.Open(databaseURL)
if err != nil { if err != nil {
return fmt.Errorf("cannot connect to PostgreSQL: %w", err) return fmt.Errorf("cannot connect to PostgreSQL: %w", err)
} }
pgDB := pgHost.DB
defer pgDB.Close() defer pgDB.Close()
if c.Bool("create-schema") { if c.Bool("create-schema") {
@@ -260,7 +269,8 @@ func execImport(c *cli.Context) error {
if err := verifySchemaVersion(pgDB, "message", expectedMessageSchemaVersion); err != nil { if err := verifySchemaVersion(pgDB, "message", expectedMessageSchemaVersion); err != nil {
return err return err
} }
if err := importMessages(cacheFile, pgDB); err != nil { sinceTime := maxMessageTime(pgDB)
if err := importMessages(cacheFile, pgDB, sinceTime); err != nil {
return fmt.Errorf("cannot import messages: %w", err) return fmt.Errorf("cannot import messages: %w", err)
} }
} }
@@ -299,6 +309,54 @@ func execImport(c *cli.Context) error {
return nil return nil
} }
func execPreImport(c *cli.Context, databaseURL, cacheFile string) error {
fmt.Println("pgimport - PRE-IMPORT mode (ntfy can keep running)")
fmt.Println()
fmt.Println("Source:")
printSource(" Cache file: ", cacheFile)
fmt.Println()
fmt.Println("Target:")
fmt.Printf(" Database URL: %s\n", maskPassword(databaseURL))
fmt.Println()
fmt.Println("This will pre-import messages into PostgreSQL while ntfy is still running.")
fmt.Println("After this completes, stop ntfy and run pgimport again without --pre-import")
fmt.Println("to import remaining messages, users, and web push subscriptions.")
fmt.Print("Continue? (y/n): ")
var answer string
fmt.Scanln(&answer)
if strings.TrimSpace(strings.ToLower(answer)) != "y" {
fmt.Println("Aborted.")
return nil
}
fmt.Println()
pgHost, err := pg.Open(databaseURL)
if err != nil {
return fmt.Errorf("cannot connect to PostgreSQL: %w", err)
}
pgDB := pgHost.DB
defer pgDB.Close()
if c.Bool("create-schema") {
if err := createSchema(pgDB, cacheFile, "", ""); err != nil {
return fmt.Errorf("cannot create schema: %w", err)
}
}
if err := verifySchemaVersion(pgDB, "message", expectedMessageSchemaVersion); err != nil {
return err
}
if err := importMessages(cacheFile, pgDB, 0); err != nil {
return fmt.Errorf("cannot import messages: %w", err)
}
fmt.Println()
fmt.Println("Pre-import complete. Now stop ntfy and run pgimport again without --pre-import")
fmt.Println("to import any remaining messages, users, and web push subscriptions.")
return nil
}
func createSchema(pgDB *sql.DB, cacheFile, authFile, webPushFile string) error { func createSchema(pgDB *sql.DB, cacheFile, authFile, webPushFile string) error {
fmt.Println("Creating initial PostgreSQL schema ...") fmt.Println("Creating initial PostgreSQL schema ...")
// User schema must be created before message schema, because message_stats and // User schema must be created before message schema, because message_stats and
@@ -644,16 +702,41 @@ func importUserPhones(sqlDB, pgDB *sql.DB) (int, error) {
// Message import // Message import
func importMessages(sqliteFile string, pgDB *sql.DB) error { const preImportTimeDelta = 30 // seconds to subtract from max time to account for in-flight messages
// maxMessageTime returns the maximum message time in PostgreSQL minus a small buffer,
// or 0 if there are no messages yet. This is used after a --pre-import run to only
// import messages that arrived since the pre-import.
func maxMessageTime(pgDB *sql.DB) int64 {
var maxTime sql.NullInt64
if err := pgDB.QueryRow(`SELECT MAX(time) FROM message`).Scan(&maxTime); err != nil || !maxTime.Valid || maxTime.Int64 == 0 {
return 0
}
sinceTime := maxTime.Int64 - preImportTimeDelta
if sinceTime < 0 {
return 0
}
fmt.Printf("Pre-imported messages detected (max time: %d), importing delta (since time %d) ...\n", maxTime.Int64, sinceTime)
return sinceTime
}
func importMessages(sqliteFile string, pgDB *sql.DB, sinceTime int64) error {
sqlDB, err := openSQLite(sqliteFile) sqlDB, err := openSQLite(sqliteFile)
if err != nil { if err != nil {
fmt.Printf("Skipping message import: %s\n", err) fmt.Printf("Skipping message import: %s\n", err)
return nil return nil
} }
defer sqlDB.Close() 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`) 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`
var rows *sql.Rows
if sinceTime > 0 {
fmt.Printf("Importing messages from %s (since time %d) ...\n", sqliteFile, sinceTime)
rows, err = sqlDB.Query(query+` WHERE time >= ?`, sinceTime)
} else {
fmt.Printf("Importing messages from %s ...\n", sqliteFile)
rows, err = sqlDB.Query(query)
}
if err != nil { if err != nil {
return fmt.Errorf("querying messages: %w", err) return fmt.Errorf("querying messages: %w", err)
} }
@@ -836,7 +919,9 @@ func importWebPush(sqliteFile string, pgDB *sql.DB) error {
} }
func toUTF8(s string) string { func toUTF8(s string) string {
return strings.ToValidUTF8(s, "\uFFFD") s = strings.ToValidUTF8(s, "\uFFFD")
s = strings.ReplaceAll(s, "\x00", "")
return s
} }
// Verification // Verification
+106 -45
View File
@@ -49,7 +49,7 @@ var (
// Manager handles user authentication, authorization, and management // Manager handles user authentication, authorization, and management
type Manager struct { type Manager struct {
config *Config config *Config
db *sql.DB db *db.DB
queries queries queries queries
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) 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) tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
@@ -58,7 +58,7 @@ type Manager struct {
var _ Auther = (*Manager)(nil) var _ Auther = (*Manager)(nil)
func newManager(db *sql.DB, queries queries, config *Config) (*Manager, error) { func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
if config.BcryptCost <= 0 { if config.BcryptCost <= 0 {
config.BcryptCost = DefaultUserPasswordBcryptCost config.BcryptCost = DefaultUserPasswordBcryptCost
} }
@@ -67,7 +67,7 @@ func newManager(db *sql.DB, queries queries, config *Config) (*Manager, error) {
} }
manager := &Manager{ manager := &Manager{
config: config, config: config,
db: db, db: d,
statsQueue: make(map[string]*Stats), statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate), tokenQueue: make(map[string]*TokenUpdate),
queries: queries, queries: queries,
@@ -288,33 +288,41 @@ func (a *Manager) ChangeTier(username, tier string) error {
t, err := a.Tier(tier) t, err := a.Tier(tier)
if err != nil { if err != nil {
return err 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 db.ExecTx(a.db, func(tx *sql.Tx) error {
return err if err := a.checkReservationsLimitTx(tx, username, t.ReservationLimit); err != nil {
} return err
return nil }
if _, err := tx.Exec(a.queries.updateUserTier, tier, username); err != nil {
return err
}
return nil
})
} }
// ResetTier removes the tier from the given user // ResetTier removes the tier from the given user
func (a *Manager) ResetTier(username string) error { func (a *Manager) ResetTier(username string) error {
if !AllowedUsername(username) && username != Everyone && username != "" { if !AllowedUsername(username) && username != Everyone && username != "" {
return ErrInvalidArgument return ErrInvalidArgument
} else if err := a.checkReservationsLimit(username, 0); err != nil {
return err
} }
_, err := a.db.Exec(a.queries.deleteUserTier, username) return db.ExecTx(a.db, func(tx *sql.Tx) error {
return err if err := a.checkReservationsLimitTx(tx, username, 0); err != nil {
return err
}
if _, err := tx.Exec(a.queries.deleteUserTier, username); err != nil {
return err
}
return nil
})
} }
func (a *Manager) checkReservationsLimit(username string, reservationsLimit int64) error { func (a *Manager) checkReservationsLimitTx(tx *sql.Tx, username string, reservationsLimit int64) error {
u, err := a.User(username) u, err := a.userTx(tx, username)
if err != nil { if err != nil {
return err return err
} }
if u.Tier != nil && reservationsLimit < u.Tier.ReservationLimit { if u.Tier != nil && reservationsLimit < u.Tier.ReservationLimit {
reservations, err := a.Reservations(username) reservations, err := a.reservationsTx(tx, username)
if err != nil { if err != nil {
return err return err
} else if int64(len(reservations)) > reservationsLimit { } else if int64(len(reservations)) > reservationsLimit {
@@ -388,7 +396,11 @@ func (a *Manager) writeUserStatsQueue() error {
// User returns the user with the given username if it exists, or ErrUserNotFound otherwise // User returns the user with the given username if it exists, or ErrUserNotFound otherwise
func (a *Manager) User(username string) (*User, error) { func (a *Manager) User(username string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByName, username) return a.userTx(a.db, username)
}
func (a *Manager) userTx(tx db.Querier, username string) (*User, error) {
rows, err := tx.Query(a.queries.selectUserByName, username)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -425,7 +437,7 @@ func (a *Manager) UserByStripeCustomer(customerID string) (*User, error) {
// Users returns a list of users. It loads all users in a single query // 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. // rather than one query per user to avoid N+1 performance issues.
func (a *Manager) Users() ([]*User, error) { func (a *Manager) Users() ([]*User, error) {
rows, err := a.db.Query(a.queries.selectUsers) rows, err := a.db.ReadOnly().Query(a.queries.selectUsers)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -434,7 +446,7 @@ func (a *Manager) Users() ([]*User, error) {
// UsersCount returns the number of users in the database // UsersCount returns the number of users in the database
func (a *Manager) UsersCount() (int64, error) { func (a *Manager) UsersCount() (int64, error) {
rows, err := a.db.Query(a.queries.selectUserCount) rows, err := a.db.ReadOnly().Query(a.queries.selectUserCount)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -642,7 +654,7 @@ func (a *Manager) AllowReservation(username string, topic string) error {
// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*" // - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*"
// - It also prioritizes write permissions over read permissions // - 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) rows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)
if err != nil { if err != nil {
return false, false, false, err return false, false, false, err
} }
@@ -660,7 +672,7 @@ func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read,
// AllGrants returns all user-specific access control entries, mapped to their respective user IDs // AllGrants returns all user-specific access control entries, mapped to their respective user IDs
func (a *Manager) AllGrants() (map[string][]Grant, error) { func (a *Manager) AllGrants() (map[string][]Grant, error) {
rows, err := a.db.Query(a.queries.selectUserAllAccess) rows, err := a.db.ReadOnly().Query(a.queries.selectUserAllAccess)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -688,7 +700,7 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
// Grants returns all user-specific access control entries // Grants returns all user-specific access control entries
func (a *Manager) Grants(username string) ([]Grant, error) { func (a *Manager) Grants(username string) ([]Grant, error) {
rows, err := a.db.Query(a.queries.selectUserAccess, username) rows, err := a.db.ReadOnly().Query(a.queries.selectUserAccess, username)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -713,16 +725,35 @@ func (a *Manager) Grants(username string) ([]Grant, error) {
// AddReservation creates two access control entries for the given topic: one with full read/write // 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 // access for the given user, and one for Everyone with the given permission. Both entries are
// created atomically in a single transaction. // created atomically in a single transaction. If limit is > 0, the reservation count is checked
func (a *Manager) AddReservation(username string, topic string, everyone Permission) error { // inside the transaction and ErrTooManyReservations is returned if the limit would be exceeded.
func (a *Manager) AddReservation(username string, topic string, everyone Permission, limit int64) error {
if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) { if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) {
return ErrInvalidArgument return ErrInvalidArgument
} }
return db.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 { if limit > 0 {
hasReservation, err := a.hasReservationTx(tx, username, topic)
if err != nil {
return err
}
if !hasReservation {
count, err := a.reservationsCountTx(tx, username)
if err != nil {
return err
}
if count >= limit {
return ErrTooManyReservations
}
}
}
if _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topic), true, true, username, username, false); err != nil {
return err return err
} }
return a.addReservationAccessTx(tx, Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username) if _, err := tx.Exec(a.queries.upsertUserAccess, Everyone, toSQLWildcard(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil {
return err
}
return nil
}) })
} }
@@ -740,10 +771,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
} }
return db.ExecTx(a.db, func(tx *sql.Tx) error { return db.ExecTx(a.db, func(tx *sql.Tx) error {
for _, topic := range topics { for _, topic := range topics {
if err := a.resetTopicAccessTx(tx, username, topic); err != nil { if err := a.removeReservationAccessTx(tx, username, topic); err != nil {
return err
}
if err := a.resetTopicAccessTx(tx, Everyone, topic); err != nil {
return err return err
} }
} }
@@ -753,7 +781,11 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
// Reservations returns all user-owned topics, and the associated everyone-access // Reservations returns all user-owned topics, and the associated everyone-access
func (a *Manager) Reservations(username string) ([]Reservation, error) { func (a *Manager) Reservations(username string) ([]Reservation, error) {
rows, err := a.db.Query(a.queries.selectUserReservations, Everyone, username) return a.reservationsTx(a.db.ReadOnly(), username)
}
func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {
rows, err := tx.Query(a.queries.selectUserReservations, Everyone, username)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -779,7 +811,11 @@ func (a *Manager) Reservations(username string) ([]Reservation, error) {
// HasReservation returns true if the given topic access is owned by the user // HasReservation returns true if the given topic access is owned by the user
func (a *Manager) HasReservation(username, topic string) (bool, error) { func (a *Manager) HasReservation(username, topic string) (bool, error) {
rows, err := a.db.Query(a.queries.selectUserHasReservation, username, escapeUnderscore(topic)) return a.hasReservationTx(a.db, username, topic)
}
func (a *Manager) hasReservationTx(tx db.Querier, username, topic string) (bool, error) {
rows, err := tx.Query(a.queries.selectUserHasReservation, username, escapeUnderscore(topic))
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -796,7 +832,11 @@ func (a *Manager) HasReservation(username, topic string) (bool, error) {
// ReservationsCount returns the number of reservations owned by this user // ReservationsCount returns the number of reservations owned by this user
func (a *Manager) ReservationsCount(username string) (int64, error) { func (a *Manager) ReservationsCount(username string) (int64, error) {
rows, err := a.db.Query(a.queries.selectUserReservationsCount, username) return a.reservationsCountTx(a.db, username)
}
func (a *Manager) reservationsCountTx(tx db.Querier, username string) (int64, error) {
rows, err := tx.Query(a.queries.selectUserReservationsCount, username)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -828,6 +868,30 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
return ownerUserID, nil return ownerUserID, nil
} }
// RemoveExcessReservations removes reservations that exceed the given limit for the user.
// It returns the list of topics whose reservations were removed. The read and removal are
// performed atomically in a single transaction to avoid issues with stale replica data.
func (a *Manager) RemoveExcessReservations(username string, limit int64) ([]string, error) {
return db.QueryTx(a.db, func(tx *sql.Tx) ([]string, error) {
reservations, err := a.reservationsTx(tx, username)
if err != nil {
return nil, err
}
if int64(len(reservations)) <= limit {
return []string{}, nil
}
removedTopics := make([]string, 0)
for i := int64(len(reservations)) - 1; i >= limit; i-- {
topic := reservations[i].Topic
if err := a.removeReservationAccessTx(tx, username, topic); err != nil {
return nil, err
}
removedTopics = append(removedTopics, topic)
}
return removedTopics, nil
})
}
// otherAccessCount returns the number of access entries for the given topic that are not owned by the user // 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) { func (a *Manager) otherAccessCount(username, topic string) (int, error) {
rows, err := a.db.Query(a.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username) rows, err := a.db.Query(a.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username)
@@ -845,14 +909,11 @@ func (a *Manager) otherAccessCount(username, topic string) (int, error) {
return count, nil return count, nil
} }
func (a *Manager) addReservationAccessTx(tx *sql.Tx, username, topic string, read, write bool, ownerUsername string) error { func (a *Manager) removeReservationAccessTx(tx *sql.Tx, username, topic string) error {
if !AllowedUsername(username) && username != Everyone { if err := a.resetTopicAccessTx(tx, username, topic); err != nil {
return ErrInvalidArgument return err
} else if !AllowedTopicPattern(topic) {
return ErrInvalidArgument
} }
_, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topic), read, write, ownerUsername, ownerUsername, false) return a.resetTopicAccessTx(tx, Everyone, topic)
return err
} }
func (a *Manager) resetUserAccessTx(tx *sql.Tx, username string) error { func (a *Manager) resetUserAccessTx(tx *sql.Tx, username string) error {
@@ -962,7 +1023,7 @@ func (a *Manager) canChangeToken(userID, token string) error {
// Token returns a specific token for a user // Token returns a specific token for a user
func (a *Manager) Token(userID, token string) (*Token, error) { func (a *Manager) Token(userID, token string) (*Token, error) {
rows, err := a.db.Query(a.queries.selectToken, userID, token) rows, err := a.db.ReadOnly().Query(a.queries.selectToken, userID, token)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -972,7 +1033,7 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
// Tokens returns all existing tokens for the user with the given user ID // Tokens returns all existing tokens for the user with the given user ID
func (a *Manager) Tokens(userID string) ([]*Token, error) { func (a *Manager) Tokens(userID string) ([]*Token, error) {
rows, err := a.db.Query(a.queries.selectTokens, userID) rows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -991,7 +1052,7 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) {
} }
func (a *Manager) allProvisionedTokens() ([]*Token, error) { func (a *Manager) allProvisionedTokens() ([]*Token, error) {
rows, err := a.db.Query(a.queries.selectAllProvisionedTokens) rows, err := a.db.ReadOnly().Query(a.queries.selectAllProvisionedTokens)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1114,7 +1175,7 @@ func (a *Manager) RemoveTier(code string) error {
// Tiers returns a list of all Tier structs // Tiers returns a list of all Tier structs
func (a *Manager) Tiers() ([]*Tier, error) { func (a *Manager) Tiers() ([]*Tier, error) {
rows, err := a.db.Query(a.queries.selectTiers) rows, err := a.db.ReadOnly().Query(a.queries.selectTiers)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1185,7 +1246,7 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
// PhoneNumbers returns all phone numbers for the user with the given user ID // PhoneNumbers returns all phone numbers for the user with the given user ID
func (a *Manager) PhoneNumbers(userID string) ([]string, error) { func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID) rows, err := a.db.ReadOnly().Query(a.queries.selectPhoneNumbers, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+4 -4
View File
@@ -1,7 +1,7 @@
package user package user
import ( import (
"database/sql" "heckel.io/ntfy/v2/db"
) )
// PostgreSQL queries // PostgreSQL queries
@@ -278,9 +278,9 @@ var postgresQueries = queries{
} }
// NewPostgresManager creates a new Manager backed by a PostgreSQL database // NewPostgresManager creates a new Manager backed by a PostgreSQL database
func NewPostgresManager(db *sql.DB, config *Config) (*Manager, error) { func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) {
if err := setupPostgres(db); err != nil { if err := setupPostgres(d.Primary()); err != nil {
return nil, err return nil, err
} }
return newManager(db, postgresQueries, config) return newManager(d, postgresQueries, config)
} }
+5 -4
View File
@@ -7,6 +7,7 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -280,15 +281,15 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager
if !util.FileExists(parentDir) { if !util.FileExists(parentDir) {
return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir) return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir)
} }
db, err := sql.Open("sqlite3", filename) d, err := sql.Open("sqlite3", filename)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := setupSQLite(db); err != nil { if err := setupSQLite(d); err != nil {
return nil, err return nil, err
} }
if err := runSQLiteStartupQueries(db, startupQueries); err != nil { if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
return nil, err return nil, err
} }
return newManager(db, sqliteQueries, config) return newManager(db.New(&db.Host{DB: d}, nil), sqliteQueries, config)
} }
+37 -16
View File
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/db/pg"
dbtest "heckel.io/ntfy/v2/db/test" dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
@@ -36,9 +37,9 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newManager newManagerFunc
t.Run("postgres", func(t *testing.T) { t.Run("postgres", func(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t) schemaDSN := dbtest.CreateTestPostgresSchema(t)
f(t, func(config *Config) *Manager { f(t, func(config *Config) *Manager {
pool, err := pg.Open(schemaDSN) host, err := pg.Open(schemaDSN)
require.Nil(t, err) require.Nil(t, err)
a, err := NewPostgresManager(pool, config) a, err := NewPostgresManager(db.New(host, nil), config)
require.Nil(t, err) require.Nil(t, err)
return a return a
}) })
@@ -225,7 +226,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
// Create user, add reservations and token // Create user, add reservations and token
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false)) require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead)) require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead, 0))
u, err := a.User("user") u, err := a.User("user")
require.Nil(t, err) require.Nil(t, err)
@@ -438,8 +439,8 @@ func TestManager_Reservations(t *testing.T) {
a := newTestManager(t, newManager, PermissionDenyAll) a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", 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", "ztopic_", PermissionDenyAll, 0))
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead)) require.Nil(t, a.AddReservation("ben", "readme", PermissionRead, 0))
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead)) require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
reservations, err := a.Reservations("ben") reservations, err := a.Reservations("ben")
@@ -522,7 +523,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
})) }))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.ChangeTier("ben", "pro")) require.Nil(t, a.ChangeTier("ben", "pro"))
require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll)) require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll, 0))
ben, err := a.User("ben") ben, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
@@ -1075,7 +1076,7 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
// Add 10 reservations (pro tier allows that) // Add 10 reservations (pro tier allows that)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite)) require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite, 0))
} }
// Downgrading will not work (too many reservations) // Downgrading will not work (too many reservations)
@@ -1734,8 +1735,8 @@ func TestMigrationFrom4(t *testing.T) {
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
} }
func checkSchemaVersion(t *testing.T, db *sql.DB) { func checkSchemaVersion(t *testing.T, d *db.DB) {
rows, err := db.Query(`SELECT version FROM schemaVersion`) rows, err := d.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err) require.Nil(t, err)
require.True(t, rows.Next()) require.True(t, rows.Next())
@@ -1771,7 +1772,7 @@ func newTestManagerFromConfig(t *testing.T, newManager newManagerFunc, conf *Con
return a return a
} }
func testDB(a *Manager) *sql.DB { func testDB(a *Manager) *db.DB {
return a.db return a.db
} }
@@ -2117,7 +2118,7 @@ func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
func TestStoreReservations(t *testing.T) { func TestStoreReservations(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionRead)) require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionRead, 0))
reservations, err := manager.Reservations("phil") reservations, err := manager.Reservations("phil")
require.Nil(t, err) require.Nil(t, err)
@@ -2132,8 +2133,8 @@ func TestStoreReservations(t *testing.T) {
func TestStoreReservationsCount(t *testing.T) { func TestStoreReservationsCount(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddReservation("phil", "topic1", PermissionReadWrite)) require.Nil(t, manager.AddReservation("phil", "topic1", PermissionReadWrite, 0))
require.Nil(t, manager.AddReservation("phil", "topic2", PermissionReadWrite)) require.Nil(t, manager.AddReservation("phil", "topic2", PermissionReadWrite, 0))
count, err := manager.ReservationsCount("phil") count, err := manager.ReservationsCount("phil")
require.Nil(t, err) require.Nil(t, err)
@@ -2144,7 +2145,7 @@ func TestStoreReservationsCount(t *testing.T) {
func TestStoreHasReservation(t *testing.T) { func TestStoreHasReservation(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite)) require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite, 0))
has, err := manager.HasReservation("phil", "mytopic") has, err := manager.HasReservation("phil", "mytopic")
require.Nil(t, err) require.Nil(t, err)
@@ -2159,7 +2160,7 @@ func TestStoreHasReservation(t *testing.T) {
func TestStoreReservationOwner(t *testing.T) { func TestStoreReservationOwner(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite)) require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite, 0))
owner, err := manager.ReservationOwner("mytopic") owner, err := manager.ReservationOwner("mytopic")
require.Nil(t, err) require.Nil(t, err)
@@ -2171,6 +2172,26 @@ func TestStoreReservationOwner(t *testing.T) {
}) })
} }
func TestStoreAddReservationWithLimit(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
// Adding reservations within limit succeeds
require.Nil(t, manager.AddReservation("phil", "topic1", PermissionReadWrite, 2))
require.Nil(t, manager.AddReservation("phil", "topic2", PermissionRead, 2))
// Adding a third reservation exceeds the limit
require.Equal(t, ErrTooManyReservations, manager.AddReservation("phil", "topic3", PermissionRead, 2))
// Updating an existing reservation within the limit succeeds
require.Nil(t, manager.AddReservation("phil", "topic1", PermissionRead, 2))
reservations, err := manager.Reservations("phil")
require.Nil(t, err)
require.Len(t, reservations, 2)
})
}
func TestStoreTiers(t *testing.T) { func TestStoreTiers(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
tier := &Tier{ tier := &Tier{
@@ -2430,7 +2451,7 @@ func TestStoreOtherAccessCount(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddUser("ben", "benpass", RoleUser, false)) require.Nil(t, manager.AddUser("ben", "benpass", RoleUser, false))
require.Nil(t, manager.AddReservation("ben", "mytopic", PermissionReadWrite)) require.Nil(t, manager.AddReservation("ben", "mytopic", PermissionReadWrite, 0))
count, err := manager.otherAccessCount("phil", "mytopic") count, err := manager.otherAccessCount("phil", "mytopic")
require.Nil(t, err) require.Nil(t, err)
+20
View File
@@ -17,6 +17,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"golang.org/x/term" "golang.org/x/term"
@@ -434,3 +435,22 @@ func Int(v int) *int {
func Time(v time.Time) *time.Time { func Time(v time.Time) *time.Time {
return &v return &v
} }
// SanitizeUTF8 ensures a string is safe to store in PostgreSQL by handling two cases:
//
// 1. Invalid UTF-8 sequences: Some clients send Latin-1/ISO-8859-1 encoded text (e.g. accented
// characters like é, ñ, ß) in HTTP headers or SMTP messages. Go treats these as raw bytes in
// strings, but PostgreSQL rejects them. Any invalid UTF-8 byte is replaced with the Unicode
// replacement character (U+FFFD, "�") so the message is still delivered rather than lost.
//
// 2. NUL bytes (0x00): These are valid in UTF-8 but PostgreSQL TEXT columns reject them.
// They are stripped entirely.
func SanitizeUTF8(s string) string {
if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, "\xef\xbf\xbd") // U+FFFD
}
if strings.ContainsRune(s, 0) {
s = strings.ReplaceAll(s, "\x00", "")
}
return s
}
+22 -21
View File
@@ -3642,9 +3642,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3766,9 +3766,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001777", "version": "1.0.30001779",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4203,9 +4203,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.307", "version": "1.5.313",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -4324,9 +4324,9 @@
} }
}, },
"node_modules/es-iterator-helpers": { "node_modules/es-iterator-helpers": {
"version": "1.2.2", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz",
"integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4345,6 +4345,7 @@
"has-symbols": "^1.1.0", "has-symbols": "^1.1.0",
"internal-slot": "^1.1.0", "internal-slot": "^1.1.0",
"iterator.prototype": "^1.1.5", "iterator.prototype": "^1.1.5",
"math-intrinsics": "^1.1.0",
"safe-array-concat": "^1.1.3" "safe-array-concat": "^1.1.3"
}, },
"engines": { "engines": {
@@ -5065,9 +5066,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.4", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -7042,9 +7043,9 @@
} }
}, },
"node_modules/path-scurry/node_modules/lru-cache": { "node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.2.6", "version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
@@ -8306,9 +8307,9 @@
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.46.0", "version": "5.46.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -120,7 +120,7 @@
"publish_dialog_priority_low": "Prioridad baja", "publish_dialog_priority_low": "Prioridad baja",
"publish_dialog_priority_high": "Prioridad alta", "publish_dialog_priority_high": "Prioridad alta",
"publish_dialog_delay_label": "Retraso", "publish_dialog_delay_label": "Retraso",
"publish_dialog_title_placeholder": "Título de la notificación, por ejemplo, Alerta de espacio en disco", "publish_dialog_title_placeholder": "Título de la notificación, ej. Alerta de espacio en disco",
"publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.", "publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.",
"publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk", "publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_placeholder": "Nombre del archivo adjunto", "publish_dialog_filename_placeholder": "Nombre del archivo adjunto",
+9 -1
View File
@@ -8,6 +8,8 @@ import routes from "../components/routes";
* support this; most importantly, all iOS browsers do not support window.Notification. * support this; most importantly, all iOS browsers do not support window.Notification.
*/ */
class Notifier { class Notifier {
lastSoundPlayedAt = 0;
async notify(subscription, notification) { async notify(subscription, notification) {
if (!this.supported()) { if (!this.supported()) {
return; return;
@@ -49,11 +51,17 @@ class Notifier {
} }
async playSound() { 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(); const sound = await prefs.sound();
if (sound && sound !== "none") { if (sound && sound !== "none") {
try { try {
await playSound(sound); await playSound(sound);
this.lastSoundPlayedAt = Date.now();
} catch (e) { } catch (e) {
console.log(`[Notifier] Error playing audio`, e); console.log(`[Notifier] Error playing audio`, e);
} }
+15 -9
View File
@@ -136,9 +136,11 @@ const ChangePassword = () => {
⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤
</Typography> </Typography>
{!account?.provisioned ? ( {!account?.provisioned ? (
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}> <Tooltip title={t("account_basics_password_description")}>
<EditIcon /> <IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
</IconButton> <EditIcon />
</IconButton>
</Tooltip>
) : ( ) : (
<Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}> <Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
<span> <span>
@@ -899,12 +901,16 @@ const TokensTable = (props) => {
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{token.token !== session.token() && !token.provisioned && ( {token.token !== session.token() && !token.provisioned && (
<> <>
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> <Tooltip title={t("account_tokens_dialog_title_edit")}>
<EditIcon /> <IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
</IconButton> <EditIcon />
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> </IconButton>
<CloseIcon /> </Tooltip>
</IconButton> <Tooltip title={t("account_tokens_dialog_title_delete")}>
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
<CloseIcon />
</IconButton>
</Tooltip>
</> </>
)} )}
{token.token === session.token() && ( {token.token === session.token() && (
+20 -12
View File
@@ -385,12 +385,16 @@ const UserTable = (props) => {
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{(!session.exists() || user.baseUrl !== config.base_url) && ( {(!session.exists() || user.baseUrl !== config.base_url) && (
<> <>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> <Tooltip title={t("prefs_users_edit_button")}>
<EditIcon /> <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
</IconButton> <EditIcon />
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> </IconButton>
<CloseIcon /> </Tooltip>
</IconButton> <Tooltip title={t("prefs_users_delete_button")}>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon />
</IconButton>
</Tooltip>
</> </>
)} )}
{session.exists() && user.baseUrl === config.base_url && ( {session.exists() && user.baseUrl === config.base_url && (
@@ -738,12 +742,16 @@ const ReservationsTable = (props) => {
/> />
</Tooltip> </Tooltip>
)} )}
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> <Tooltip title={t("prefs_reservations_edit_button")}>
<EditIcon /> <IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
</IconButton> <EditIcon />
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> </IconButton>
<CloseIcon /> </Tooltip>
</IconButton> <Tooltip title={t("prefs_reservations_delete_button")}>
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
<CloseIcon />
</IconButton>
</Tooltip>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
+6 -5
View File
@@ -24,7 +24,7 @@ var (
// Store holds the database connection and queries for web push subscriptions. // Store holds the database connection and queries for web push subscriptions.
type Store struct { type Store struct {
db *sql.DB db *db.DB
queries queries queries queries
} }
@@ -63,9 +63,10 @@ func (s *Store) UpsertSubscription(endpoint string, auth, p256dh, userID string,
} else if err != nil { } else if err != nil {
return err return err
} }
// Insert or update subscription // Insert or update subscription, and read back the actual ID (which may differ from
// the generated one if another request for the same endpoint raced us and inserted first)
updatedAt, warnedAt := time.Now().Unix(), 0 updatedAt, warnedAt := time.Now().Unix(), 0
if _, err := tx.Exec(s.queries.upsertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil { if err := tx.QueryRow(s.queries.upsertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt).Scan(&subscriptionID); err != nil {
return err return err
} }
// Replace all subscription topics // Replace all subscription topics
@@ -83,7 +84,7 @@ func (s *Store) UpsertSubscription(endpoint string, auth, p256dh, userID string,
// SubscriptionsForTopic returns all subscriptions for the given topic. // SubscriptionsForTopic returns all subscriptions for the given topic.
func (s *Store) SubscriptionsForTopic(topic string) ([]*Subscription, error) { func (s *Store) SubscriptionsForTopic(topic string) ([]*Subscription, error) {
rows, err := s.db.Query(s.queries.selectSubscriptionsForTopic, topic) rows, err := s.db.ReadOnly().Query(s.queries.selectSubscriptionsForTopic, topic)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -93,7 +94,7 @@ func (s *Store) SubscriptionsForTopic(topic string) ([]*Subscription, error) {
// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period. // SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period.
func (s *Store) 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()) rows, err := s.db.ReadOnly().Query(s.queries.selectSubscriptionsExpiringSoon, time.Now().Add(-warnAfter).Unix())
if err != nil { if err != nil {
return nil, err return nil, err
} }
+9 -8
View File
@@ -53,6 +53,7 @@ const (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (endpoint) 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
RETURNING id
` `
postgresUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2` postgresUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2`
postgresUpdateSubscriptionUpdatedAtQuery = `UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2` postgresUpdateSubscriptionUpdatedAtQuery = `UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2`
@@ -73,12 +74,12 @@ const (
) )
// NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool. // NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool.
func NewPostgresStore(db *sql.DB) (*Store, error) { func NewPostgresStore(d *db.DB) (*Store, error) {
if err := setupPostgres(db); err != nil { if err := setupPostgres(d.Primary()); err != nil {
return nil, err return nil, err
} }
return &Store{ return &Store{
db: db, db: d,
queries: queries{ queries: queries{
selectSubscriptionIDByEndpoint: postgresSelectSubscriptionIDByEndpointQuery, selectSubscriptionIDByEndpoint: postgresSelectSubscriptionIDByEndpointQuery,
selectSubscriptionCountBySubscriberIP: postgresSelectSubscriptionCountBySubscriberIPQuery, selectSubscriptionCountBySubscriberIP: postgresSelectSubscriptionCountBySubscriberIPQuery,
@@ -97,11 +98,11 @@ func NewPostgresStore(db *sql.DB) (*Store, error) {
}, nil }, nil
} }
func setupPostgres(db *sql.DB) error { func setupPostgres(d *sql.DB) error {
var schemaVersion int var schemaVersion int
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil { if err != nil {
return setupNewPostgres(db) return setupNewPostgres(d)
} }
if schemaVersion > pgCurrentSchemaVersion { if schemaVersion > pgCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion) return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion)
@@ -109,8 +110,8 @@ func setupPostgres(db *sql.DB) error {
return nil return nil
} }
func setupNewPostgres(sqlDB *sql.DB) error { func setupNewPostgres(d *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error { return db.ExecTx(d, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
return err return err
} }
+6 -5
View File
@@ -56,8 +56,9 @@ const (
sqliteUpsertSubscriptionQuery = ` sqliteUpsertSubscriptionQuery = `
INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (endpoint) 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
RETURNING id
` `
sqliteUpdateSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?` sqliteUpdateSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
sqliteUpdateSubscriptionUpdatedAtQuery = `UPDATE subscription SET updated_at = ? WHERE endpoint = ?` sqliteUpdateSubscriptionUpdatedAtQuery = `UPDATE subscription SET updated_at = ? WHERE endpoint = ?`
@@ -79,18 +80,18 @@ const (
// NewSQLiteStore creates a new SQLite-backed web push store. // 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) d, err := sql.Open("sqlite3", filename)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := setupSQLite(db); err != nil { if err := setupSQLite(d); err != nil {
return nil, err return nil, err
} }
if err := runSQLiteStartupQueries(db, startupQueries); err != nil { if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
return nil, err return nil, err
} }
return &Store{ return &Store{
db: db, db: db.New(&db.Host{DB: d}, nil),
queries: queries{ queries: queries{
selectSubscriptionIDByEndpoint: sqliteSelectSubscriptionIDByEndpointQuery, selectSubscriptionIDByEndpoint: sqliteSelectSubscriptionIDByEndpointQuery,
selectSubscriptionCountBySubscriberIP: sqliteSelectSubscriptionCountBySubscriberIPQuery, selectSubscriptionCountBySubscriberIP: sqliteSelectSubscriptionCountBySubscriberIPQuery,