Compare commits

...
Author SHA1 Message Date
binwiederhier 22868f4742 Derp 2026-03-14 14:21:09 -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
35 changed files with 4171 additions and 255 deletions
+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://") {
return errors.New("if database-url is set, it must start with postgres://")
} 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
} }
+19
View File
@@ -0,0 +1,19 @@
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)
}
// 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
}
+294 -11
View File
@@ -135,6 +135,264 @@ 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 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 +407,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 +419,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 +465,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 +2101,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) |
+12 -3
View File
@@ -12,7 +12,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
Please check out the release notes for [upcoming releases](#not-released-yet) below. Please check out the release notes for [upcoming releases](#not-released-yet) below.
### ntfy server v2.18.0 ## ntfy server v2.18.0
Released March 7, 2026 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 +33,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 +1755,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
_None_ ### ntfy server v2.19.x (UNRELEASED)
**Features:**
* Support PostgreSQL read replicas for offloading non-critical read queries via `database-replica-urls` config option
**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)
+764
View File
@@ -0,0 +1,764 @@
/* 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;
}
/* Responsive */
@media (max-width: 900px) {
.cg-modal-dialog {
inset: 8px;
}
.cg-modal-body {
flex-direction: column;
}
#cg-left {
border-right: none;
border-bottom: 1px solid #ddd;
}
#cg-right {
flex: 1;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

+1220
View File
File diff suppressed because it is too large Load Diff
+1339
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.1 // 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.1 h1:VELCSvZKiSw0AS1k3so5mKGy3CB7bTCYD8EHhTF42bY=
cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0= cloud.google.com/go/storage v1.61.1/go.mod h1:k30/hwYfd0M8aULYbPkQLgNf+SFcdjlRHvLMXggw18E=
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=
+15 -13
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)
@@ -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
+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
+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
+19 -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 {
+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()
+2 -1
View File
@@ -236,10 +236,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") {
+17 -17
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,
@@ -415,7 +415,7 @@ func (a *Manager) userByToken(token string) (*User, error) {
// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise // UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise
func (a *Manager) UserByStripeCustomer(customerID string) (*User, error) { func (a *Manager) UserByStripeCustomer(customerID string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByStripeCustomerID, customerID) rows, err := a.db.ReadOnly().Query(a.queries.selectUserByStripeCustomerID, customerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -425,7 +425,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 +434,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
} }
@@ -660,7 +660,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 +688,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
} }
@@ -753,7 +753,7 @@ 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) rows, err := a.db.ReadOnly().Query(a.queries.selectUserReservations, Everyone, username)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -796,7 +796,7 @@ 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) rows, err := a.db.ReadOnly().Query(a.queries.selectUserReservationsCount, username)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -962,7 +962,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 +972,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 +991,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 +1114,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
} }
@@ -1134,7 +1134,7 @@ func (a *Manager) Tiers() ([]*Tier, error) {
// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist // Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist
func (a *Manager) Tier(code string) (*Tier, error) { func (a *Manager) Tier(code string) (*Tier, error) {
rows, err := a.db.Query(a.queries.selectTierByCode, code) rows, err := a.db.ReadOnly().Query(a.queries.selectTierByCode, code)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1144,7 +1144,7 @@ func (a *Manager) Tier(code string) (*Tier, error) {
// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist // TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist
func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
rows, err := a.db.Query(a.queries.selectTierByPriceID, priceID, priceID) rows, err := a.db.ReadOnly().Query(a.queries.selectTierByPriceID, priceID, priceID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1185,7 +1185,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)
} }
+6 -5
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
}) })
@@ -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
} }
+7 -6
View File
@@ -4324,9 +4324,9 @@
} }
}, },
"node_modules/es-iterator-helpers": { "node_modules/es-iterator-helpers": {
"version": "1.2.2", "version": "1.3.0",
"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.0.tgz",
"integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", "integrity": "sha512-04cg8iJFDOxWcYlu0GFFWgs7vtaEPCmr5w1nrj9V3z3axu/48HCMwK6VMp45Zh3ZB+xLP1ifbJfrq86+1ypKKQ==",
"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"
}, },
+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>
))} ))}
+3 -3
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
} }
@@ -83,7 +83,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 +93,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
} }
+8 -8
View File
@@ -73,12 +73,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 +97,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 +109,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
} }
+4 -4
View File
@@ -79,18 +79,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,