diff --git a/cmd/serve.go b/cmd/serve.go index 5dcfcaff..415868fc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}), @@ -145,6 +146,7 @@ func execServe(c *cli.Context) error { certFile := c.String("cert-file") firebaseKeyFile := c.String("firebase-key-file") databaseURL := c.String("database-url") + databaseReplicaURLs := c.StringSlice("database-replica-urls") webPushPrivateKey := c.String("web-push-private-key") webPushPublicKey := c.String("web-push-public-key") webPushFile := c.String("web-push-file") @@ -286,6 +288,8 @@ func execServe(c *cli.Context) error { 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") + } 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) { return errors.New("if set, FCM key file must exist") } else if firebaseKeyFile != "" && !server.FirebaseAvailable { @@ -504,6 +508,7 @@ func execServe(c *cli.Context) error { conf.MetricsListenHTTP = metricsListenHTTP conf.ProfileListenHTTP = profileListenHTTP conf.DatabaseURL = databaseURL + conf.DatabaseReplicaURLs = databaseReplicaURLs conf.WebPushPrivateKey = webPushPrivateKey conf.WebPushPublicKey = webPushPublicKey conf.WebPushFile = webPushFile diff --git a/cmd/user.go b/cmd/user.go index 1ffc3e6b..cd6cf795 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -11,6 +11,7 @@ import ( "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" @@ -379,11 +380,11 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, } if databaseURL != "" { - pool, dbErr := pg.Open(databaseURL) + host, dbErr := pg.Open(databaseURL) if dbErr != nil { return nil, dbErr } - return user.NewPostgresManager(pool, authConfig) + return user.NewPostgresManager(db.New(host, nil), authConfig) } else if authFile != "" { if !util.FileExists(authFile) { return nil, errors.New("auth-file does not exist; please start the server at least once to create it") diff --git a/db/db.go b/db/db.go index 00ae91f4..6586e763 100644 --- a/db/db.go +++ b/db/db.go @@ -1,38 +1,137 @@ package db import ( + "context" "database/sql" + "sync/atomic" + "time" + + "heckel.io/ntfy/v2/log" ) -// ExecTx executes a function within a database transaction. If the function returns an error, -// the transaction is rolled back. Otherwise, the transaction is committed. -func ExecTx(db *sql.DB, f func(tx *sql.Tx) error) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := f(tx); err != nil { - return err - } - return tx.Commit() +const ( + tag = "db" + replicaHealthCheckInitialDelay = 5 * time.Second + replicaHealthCheckInterval = 30 * time.Second + replicaHealthCheckTimeout = 10 * time.Second +) + +// DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods +// delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica +// (round-robin), falling back to the primary if no replicas are configured or all are unhealthy. +type DB struct { + 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 -// returns an error, the transaction is rolled back. Otherwise, the transaction is committed. -func QueryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) { - tx, err := db.Begin() - if err != nil { - var zero T - return zero, err +// New creates a new DB that wraps the given primary and optional replica connections. +// If replicas is nil or empty, ReadOnly() simply returns the primary. +// Replicas start unhealthy and are checked immediately by a background goroutine. +func New(primary *Host, replicas []*Host) *DB { + ctx, cancel := context.WithCancel(context.Background()) + d := &DB{ + primary: primary, + replicas: replicas, + cancel: cancel, } - defer tx.Rollback() - t, err := f(tx) - if err != nil { - return t, err + if len(d.replicas) > 0 { + go d.healthCheckLoop(ctx) + } + 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 } diff --git a/db/pg/pg.go b/db/pg/pg.go index 99f802b3..3b034736 100644 --- a/db/pg/pg.go +++ b/db/pg/pg.go @@ -5,65 +5,84 @@ import ( "fmt" "net/url" "strconv" + "strings" "time" _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver + + "heckel.io/ntfy/v2/db" ) -const ( - paramMaxOpenConns = "pool_max_conns" - paramMaxIdleConns = "pool_max_idle_conns" - paramConnMaxLifetime = "pool_conn_max_lifetime" - paramConnMaxIdleTime = "pool_conn_max_idle_time" +// Open opens a PostgreSQL connection pool for a primary database. It pings the database +// to verify connectivity before returning. +func Open(dsn string) (*db.Host, error) { + d, err := open(dsn) + 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, // pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from // the DSN before passing it to the driver. -func Open(dsn string) (*sql.DB, error) { +func open(dsn string) (*db.Host, error) { u, err := url.Parse(dsn) if err != nil { 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() - maxOpenConns, err := extractIntParam(q, paramMaxOpenConns, defaultMaxOpenConns) + maxOpenConns, err := extractIntParam(q, "pool_max_conns", 10) if err != nil { return nil, err } - maxIdleConns, err := extractIntParam(q, paramMaxIdleConns, 0) + maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0) if err != nil { return nil, err } - connMaxLifetime, err := extractDurationParam(q, paramConnMaxLifetime, 0) + connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0) if err != nil { return nil, err } - connMaxIdleTime, err := extractDurationParam(q, paramConnMaxIdleTime, 0) + connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0) if err != nil { return nil, err } u.RawQuery = q.Encode() - db, err := sql.Open("pgx", u.String()) + d, err := sql.Open("pgx", u.String()) if err != nil { return nil, err } - db.SetMaxOpenConns(maxOpenConns) + d.SetMaxOpenConns(maxOpenConns) if maxIdleConns > 0 { - db.SetMaxIdleConns(maxIdleConns) + d.SetMaxIdleConns(maxIdleConns) } if connMaxLifetime > 0 { - db.SetConnMaxLifetime(connMaxLifetime) + d.SetConnMaxLifetime(connMaxLifetime) } if connMaxIdleTime > 0 { - db.SetConnMaxIdleTime(connMaxIdleTime) + d.SetConnMaxIdleTime(connMaxIdleTime) } - if err := db.Ping(); err != nil { - return nil, fmt.Errorf("ping failed: %w", err) - } - return db, nil + return &db.Host{ + Addr: u.Host, + DB: d, + }, nil } 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 } +// 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) { s := q.Get(key) if s == "" { diff --git a/db/pg/pg_test.go b/db/pg/pg_test.go new file mode 100644 index 00000000..cc66d7e9 --- /dev/null +++ b/db/pg/pg_test.go @@ -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)) + }) + } +} diff --git a/db/test/test.go b/db/test/test.go index 36c3fc86..8d3f329b 100644 --- a/db/test/test.go +++ b/db/test/test.go @@ -1,13 +1,13 @@ package dbtest import ( - "database/sql" "fmt" "net/url" "os" "testing" "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/util" ) @@ -30,34 +30,35 @@ func CreateTestPostgresSchema(t *testing.T) string { q.Set("pool_max_conns", testPoolMaxConns) u.RawQuery = q.Encode() dsn = u.String() - setupDB, err := pg.Open(dsn) + setupHost, err := pg.Open(dsn) 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, setupDB.Close()) + require.Nil(t, setupHost.DB.Close()) q.Set("search_path", schema) u.RawQuery = q.Encode() schemaDSN := u.String() t.Cleanup(func() { - cleanDB, err := pg.Open(dsn) + cleanHost, err := pg.Open(dsn) if err == nil { - cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) - cleanDB.Close() + cleanHost.DB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema)) + cleanHost.DB.Close() } }) 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. // 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() schemaDSN := CreateTestPostgresSchema(t) - testDB, err := pg.Open(schemaDSN) + testHost, err := pg.Open(schemaDSN) require.Nil(t, err) + d := db.New(testHost, nil) t.Cleanup(func() { - testDB.Close() + d.Close() }) - return testDB + return d } diff --git a/db/types.go b/db/types.go new file mode 100644 index 00000000..534d6168 --- /dev/null +++ b/db/types.go @@ -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 +} diff --git a/db/util.go b/db/util.go new file mode 100644 index 00000000..4621cb38 --- /dev/null +++ b/db/util.go @@ -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 +} diff --git a/docs/config.md b/docs/config.md index 8e07613c..b7ae1265 100644 --- a/docs/config.md +++ b/docs/config.md @@ -399,11 +399,7 @@ no external dependencies: ### PostgreSQL (EXPERIMENTAL) As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the -`database-url` option to a PostgreSQL connection string: - -```yaml -database-url: "postgres://user:pass@host:5432/ntfy" -``` +`database-url` option to a PostgreSQL connection string. 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`, @@ -415,11 +411,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`. +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) as query parameters, such as `sslmode`, `connect_timeout`, `sslcert`, `sslkey`, `sslrootcert`, and `application_name`. See the [pgx driver documentation](https://pkg.go.dev/github.com/jackc/pgx/v5) for the full list of supported parameters. -In addition, ntfy supports the following custom query parameters to tune the connection pool: +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 | |---------------------------|---------|----------------------------------------------------------------------------------| @@ -428,11 +457,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_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 If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period @@ -2069,6 +2093,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. | | `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-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-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | | `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | diff --git a/docs/releases.md b/docs/releases.md index 15018d88..6ca2b86f 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1757,6 +1757,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ### 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) diff --git a/docs/static/css/config-generator.css b/docs/static/css/config-generator.css index dffbd6ed..5acd5fe0 100644 --- a/docs/static/css/config-generator.css +++ b/docs/static/css/config-generator.css @@ -549,13 +549,20 @@ 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: 3px 7px; + padding: 5px 8px; color: #999; line-height: 1; } @@ -714,6 +721,12 @@ body[data-md-color-scheme="slate"] .cg-repeatable-row select { 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; diff --git a/docs/static/js/bcrypt.js b/docs/static/js/bcrypt.js new file mode 100644 index 00000000..517ea20d --- /dev/null +++ b/docs/static/js/bcrypt.js @@ -0,0 +1,1220 @@ +// GENERATED FILE. DO NOT EDIT. +(function (global, factory) { + function preferDefault(exports) { + return exports.default || exports; + } + if (typeof define === "function" && define.amd) { + define(["crypto"], function (_crypto) { + var exports = {}; + factory(exports, _crypto); + return preferDefault(exports); + }); + } else if (typeof exports === "object") { + factory(exports, require("crypto")); + if (typeof module === "object") module.exports = preferDefault(exports); + } else { + (function () { + var exports = {}; + factory(exports, global.crypto); + global.bcrypt = preferDefault(exports); + })(); + } +})( + typeof globalThis !== "undefined" + ? globalThis + : typeof self !== "undefined" + ? self + : this, + function (_exports, _crypto) { + "use strict"; + + Object.defineProperty(_exports, "__esModule", { + value: true, + }); + _exports.compare = compare; + _exports.compareSync = compareSync; + _exports.decodeBase64 = decodeBase64; + _exports.default = void 0; + _exports.encodeBase64 = encodeBase64; + _exports.genSalt = genSalt; + _exports.genSaltSync = genSaltSync; + _exports.getRounds = getRounds; + _exports.getSalt = getSalt; + _exports.hash = hash; + _exports.hashSync = hashSync; + _exports.setRandomFallback = setRandomFallback; + _exports.truncates = truncates; + _crypto = _interopRequireDefault(_crypto); + function _interopRequireDefault(e) { + return e && e.__esModule ? e : { default: e }; + } + /* + Copyright (c) 2012 Nevins Bartolomeo + Copyright (c) 2012 Shane Girish + Copyright (c) 2025 Daniel Wirtz + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + + // The Node.js crypto module is used as a fallback for the Web Crypto API. When + // building for the browser, inclusion of the crypto module should be disabled, + // which the package hints at in its package.json for bundlers that support it. + + /** + * The random implementation to use as a fallback. + * @type {?function(number):!Array.} + * @inner + */ + var randomFallback = null; + + /** + * Generates cryptographically secure random bytes. + * @function + * @param {number} len Bytes length + * @returns {!Array.} Random bytes + * @throws {Error} If no random implementation is available + * @inner + */ + function randomBytes(len) { + // Web Crypto API. Globally available in the browser and in Node.js >=23. + try { + return crypto.getRandomValues(new Uint8Array(len)); + } catch {} + // Node.js crypto module for non-browser environments. + try { + return _crypto.default.randomBytes(len); + } catch {} + // Custom fallback specified with `setRandomFallback`. + if (!randomFallback) { + throw Error( + "Neither WebCryptoAPI nor a crypto module is available. Use bcrypt.setRandomFallback to set an alternative", + ); + } + return randomFallback(len); + } + + /** + * Sets the pseudo random number generator to use as a fallback if neither node's `crypto` module nor the Web Crypto + * API is available. Please note: It is highly important that the PRNG used is cryptographically secure and that it + * is seeded properly! + * @param {?function(number):!Array.} random Function taking the number of bytes to generate as its + * sole argument, returning the corresponding array of cryptographically secure random byte values. + * @see http://nodejs.org/api/crypto.html + * @see http://www.w3.org/TR/WebCryptoAPI/ + */ + function setRandomFallback(random) { + randomFallback = random; + } + + /** + * Synchronously generates a salt. + * @param {number=} rounds Number of rounds to use, defaults to 10 if omitted + * @param {number=} seed_length Not supported. + * @returns {string} Resulting salt + * @throws {Error} If a random fallback is required but not set + */ + function genSaltSync(rounds, seed_length) { + rounds = rounds || GENSALT_DEFAULT_LOG2_ROUNDS; + if (typeof rounds !== "number") + throw Error( + "Illegal arguments: " + typeof rounds + ", " + typeof seed_length, + ); + if (rounds < 4) rounds = 4; + else if (rounds > 31) rounds = 31; + var salt = []; + salt.push("$2b$"); + if (rounds < 10) salt.push("0"); + salt.push(rounds.toString()); + salt.push("$"); + salt.push(base64_encode(randomBytes(BCRYPT_SALT_LEN), BCRYPT_SALT_LEN)); // May throw + return salt.join(""); + } + + /** + * Asynchronously generates a salt. + * @param {(number|function(Error, string=))=} rounds Number of rounds to use, defaults to 10 if omitted + * @param {(number|function(Error, string=))=} seed_length Not supported. + * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting salt + * @returns {!Promise} If `callback` has been omitted + * @throws {Error} If `callback` is present but not a function + */ + function genSalt(rounds, seed_length, callback) { + if (typeof seed_length === "function") + (callback = seed_length), (seed_length = undefined); // Not supported. + if (typeof rounds === "function") + (callback = rounds), (rounds = undefined); + if (typeof rounds === "undefined") rounds = GENSALT_DEFAULT_LOG2_ROUNDS; + else if (typeof rounds !== "number") + throw Error("illegal arguments: " + typeof rounds); + function _async(callback) { + nextTick(function () { + // Pretty thin, but salting is fast enough + try { + callback(null, genSaltSync(rounds)); + } catch (err) { + callback(err); + } + }); + } + if (callback) { + if (typeof callback !== "function") + throw Error("Illegal callback: " + typeof callback); + _async(callback); + } else + return new Promise(function (resolve, reject) { + _async(function (err, res) { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); + } + + /** + * Synchronously generates a hash for the given password. + * @param {string} password Password to hash + * @param {(number|string)=} salt Salt length to generate or salt to use, default to 10 + * @returns {string} Resulting hash + */ + function hashSync(password, salt) { + if (typeof salt === "undefined") salt = GENSALT_DEFAULT_LOG2_ROUNDS; + if (typeof salt === "number") salt = genSaltSync(salt); + if (typeof password !== "string" || typeof salt !== "string") + throw Error( + "Illegal arguments: " + typeof password + ", " + typeof salt, + ); + return _hash(password, salt); + } + + /** + * Asynchronously generates a hash for the given password. + * @param {string} password Password to hash + * @param {number|string} salt Salt length to generate or salt to use + * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting hash + * @param {function(number)=} progressCallback Callback successively called with the percentage of rounds completed + * (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms. + * @returns {!Promise} If `callback` has been omitted + * @throws {Error} If `callback` is present but not a function + */ + function hash(password, salt, callback, progressCallback) { + function _async(callback) { + if (typeof password === "string" && typeof salt === "number") + genSalt(salt, function (err, salt) { + _hash(password, salt, callback, progressCallback); + }); + else if (typeof password === "string" && typeof salt === "string") + _hash(password, salt, callback, progressCallback); + else + nextTick( + callback.bind( + this, + Error( + "Illegal arguments: " + typeof password + ", " + typeof salt, + ), + ), + ); + } + if (callback) { + if (typeof callback !== "function") + throw Error("Illegal callback: " + typeof callback); + _async(callback); + } else + return new Promise(function (resolve, reject) { + _async(function (err, res) { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); + } + + /** + * Compares two strings of the same length in constant time. + * @param {string} known Must be of the correct length + * @param {string} unknown Must be the same length as `known` + * @returns {boolean} + * @inner + */ + function safeStringCompare(known, unknown) { + var diff = known.length ^ unknown.length; + for (var i = 0; i < known.length; ++i) { + diff |= known.charCodeAt(i) ^ unknown.charCodeAt(i); + } + return diff === 0; + } + + /** + * Synchronously tests a password against a hash. + * @param {string} password Password to compare + * @param {string} hash Hash to test against + * @returns {boolean} true if matching, otherwise false + * @throws {Error} If an argument is illegal + */ + function compareSync(password, hash) { + if (typeof password !== "string" || typeof hash !== "string") + throw Error( + "Illegal arguments: " + typeof password + ", " + typeof hash, + ); + if (hash.length !== 60) return false; + return safeStringCompare( + hashSync(password, hash.substring(0, hash.length - 31)), + hash, + ); + } + + /** + * Asynchronously tests a password against a hash. + * @param {string} password Password to compare + * @param {string} hashValue Hash to test against + * @param {function(Error, boolean)=} callback Callback receiving the error, if any, otherwise the result + * @param {function(number)=} progressCallback Callback successively called with the percentage of rounds completed + * (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms. + * @returns {!Promise} If `callback` has been omitted + * @throws {Error} If `callback` is present but not a function + */ + function compare(password, hashValue, callback, progressCallback) { + function _async(callback) { + if (typeof password !== "string" || typeof hashValue !== "string") { + nextTick( + callback.bind( + this, + Error( + "Illegal arguments: " + + typeof password + + ", " + + typeof hashValue, + ), + ), + ); + return; + } + if (hashValue.length !== 60) { + nextTick(callback.bind(this, null, false)); + return; + } + hash( + password, + hashValue.substring(0, 29), + function (err, comp) { + if (err) callback(err); + else callback(null, safeStringCompare(comp, hashValue)); + }, + progressCallback, + ); + } + if (callback) { + if (typeof callback !== "function") + throw Error("Illegal callback: " + typeof callback); + _async(callback); + } else + return new Promise(function (resolve, reject) { + _async(function (err, res) { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); + } + + /** + * Gets the number of rounds used to encrypt the specified hash. + * @param {string} hash Hash to extract the used number of rounds from + * @returns {number} Number of rounds used + * @throws {Error} If `hash` is not a string + */ + function getRounds(hash) { + if (typeof hash !== "string") + throw Error("Illegal arguments: " + typeof hash); + return parseInt(hash.split("$")[2], 10); + } + + /** + * Gets the salt portion from a hash. Does not validate the hash. + * @param {string} hash Hash to extract the salt from + * @returns {string} Extracted salt part + * @throws {Error} If `hash` is not a string or otherwise invalid + */ + function getSalt(hash) { + if (typeof hash !== "string") + throw Error("Illegal arguments: " + typeof hash); + if (hash.length !== 60) + throw Error("Illegal hash length: " + hash.length + " != 60"); + return hash.substring(0, 29); + } + + /** + * Tests if a password will be truncated when hashed, that is its length is + * greater than 72 bytes when converted to UTF-8. + * @param {string} password The password to test + * @returns {boolean} `true` if truncated, otherwise `false` + */ + function truncates(password) { + if (typeof password !== "string") + throw Error("Illegal arguments: " + typeof password); + return utf8Length(password) > 72; + } + + /** + * Continues with the callback after yielding to the event loop. + * @function + * @param {function(...[*])} callback Callback to execute + * @inner + */ + var nextTick = + typeof setImmediate === "function" + ? setImmediate + : typeof scheduler === "object" && + typeof scheduler.postTask === "function" + ? scheduler.postTask.bind(scheduler) + : setTimeout; + + /** Calculates the byte length of a string encoded as UTF8. */ + function utf8Length(string) { + var len = 0, + c = 0; + for (var i = 0; i < string.length; ++i) { + c = string.charCodeAt(i); + if (c < 128) len += 1; + else if (c < 2048) len += 2; + else if ( + (c & 0xfc00) === 0xd800 && + (string.charCodeAt(i + 1) & 0xfc00) === 0xdc00 + ) { + ++i; + len += 4; + } else len += 3; + } + return len; + } + + /** Converts a string to an array of UTF8 bytes. */ + function utf8Array(string) { + var offset = 0, + c1, + c2; + var buffer = new Array(utf8Length(string)); + for (var i = 0, k = string.length; i < k; ++i) { + c1 = string.charCodeAt(i); + if (c1 < 128) { + buffer[offset++] = c1; + } else if (c1 < 2048) { + buffer[offset++] = (c1 >> 6) | 192; + buffer[offset++] = (c1 & 63) | 128; + } else if ( + (c1 & 0xfc00) === 0xd800 && + ((c2 = string.charCodeAt(i + 1)) & 0xfc00) === 0xdc00 + ) { + c1 = 0x10000 + ((c1 & 0x03ff) << 10) + (c2 & 0x03ff); + ++i; + buffer[offset++] = (c1 >> 18) | 240; + buffer[offset++] = ((c1 >> 12) & 63) | 128; + buffer[offset++] = ((c1 >> 6) & 63) | 128; + buffer[offset++] = (c1 & 63) | 128; + } else { + buffer[offset++] = (c1 >> 12) | 224; + buffer[offset++] = ((c1 >> 6) & 63) | 128; + buffer[offset++] = (c1 & 63) | 128; + } + } + return buffer; + } + + // A base64 implementation for the bcrypt algorithm. This is partly non-standard. + + /** + * bcrypt's own non-standard base64 dictionary. + * @type {!Array.} + * @const + * @inner + **/ + var BASE64_CODE = + "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split( + "", + ); + + /** + * @type {!Array.} + * @const + * @inner + **/ + var BASE64_INDEX = [ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 1, 54, 55, 56, 57, 58, 59, 60, + 61, 62, 63, -1, -1, -1, -1, -1, -1, -1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, -1, -1, + -1, -1, -1, -1, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, + 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, -1, -1, -1, -1, -1, + ]; + + /** + * Encodes a byte array to base64 with up to len bytes of input. + * @param {!Array.} b Byte array + * @param {number} len Maximum input length + * @returns {string} + * @inner + */ + function base64_encode(b, len) { + var off = 0, + rs = [], + c1, + c2; + if (len <= 0 || len > b.length) throw Error("Illegal len: " + len); + while (off < len) { + c1 = b[off++] & 0xff; + rs.push(BASE64_CODE[(c1 >> 2) & 0x3f]); + c1 = (c1 & 0x03) << 4; + if (off >= len) { + rs.push(BASE64_CODE[c1 & 0x3f]); + break; + } + c2 = b[off++] & 0xff; + c1 |= (c2 >> 4) & 0x0f; + rs.push(BASE64_CODE[c1 & 0x3f]); + c1 = (c2 & 0x0f) << 2; + if (off >= len) { + rs.push(BASE64_CODE[c1 & 0x3f]); + break; + } + c2 = b[off++] & 0xff; + c1 |= (c2 >> 6) & 0x03; + rs.push(BASE64_CODE[c1 & 0x3f]); + rs.push(BASE64_CODE[c2 & 0x3f]); + } + return rs.join(""); + } + + /** + * Decodes a base64 encoded string to up to len bytes of output. + * @param {string} s String to decode + * @param {number} len Maximum output length + * @returns {!Array.} + * @inner + */ + function base64_decode(s, len) { + var off = 0, + slen = s.length, + olen = 0, + rs = [], + c1, + c2, + c3, + c4, + o, + code; + if (len <= 0) throw Error("Illegal len: " + len); + while (off < slen - 1 && olen < len) { + code = s.charCodeAt(off++); + c1 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1; + code = s.charCodeAt(off++); + c2 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1; + if (c1 == -1 || c2 == -1) break; + o = (c1 << 2) >>> 0; + o |= (c2 & 0x30) >> 4; + rs.push(String.fromCharCode(o)); + if (++olen >= len || off >= slen) break; + code = s.charCodeAt(off++); + c3 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1; + if (c3 == -1) break; + o = ((c2 & 0x0f) << 4) >>> 0; + o |= (c3 & 0x3c) >> 2; + rs.push(String.fromCharCode(o)); + if (++olen >= len || off >= slen) break; + code = s.charCodeAt(off++); + c4 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1; + o = ((c3 & 0x03) << 6) >>> 0; + o |= c4; + rs.push(String.fromCharCode(o)); + ++olen; + } + var res = []; + for (off = 0; off < olen; off++) res.push(rs[off].charCodeAt(0)); + return res; + } + + /** + * @type {number} + * @const + * @inner + */ + var BCRYPT_SALT_LEN = 16; + + /** + * @type {number} + * @const + * @inner + */ + var GENSALT_DEFAULT_LOG2_ROUNDS = 10; + + /** + * @type {number} + * @const + * @inner + */ + var BLOWFISH_NUM_ROUNDS = 16; + + /** + * @type {number} + * @const + * @inner + */ + var MAX_EXECUTION_TIME = 100; + + /** + * @type {Array.} + * @const + * @inner + */ + var P_ORIG = [ + 0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344, 0xa4093822, 0x299f31d0, + 0x082efa98, 0xec4e6c89, 0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c, + 0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917, 0x9216d5d9, 0x8979fb1b, + ]; + + /** + * @type {Array.} + * @const + * @inner + */ + var S_ORIG = [ + 0xd1310ba6, 0x98dfb5ac, 0x2ffd72db, 0xd01adfb7, 0xb8e1afed, 0x6a267e96, + 0xba7c9045, 0xf12c7f99, 0x24a19947, 0xb3916cf7, 0x0801f2e2, 0x858efc16, + 0x636920d8, 0x71574e69, 0xa458fea3, 0xf4933d7e, 0x0d95748f, 0x728eb658, + 0x718bcd58, 0x82154aee, 0x7b54a41d, 0xc25a59b5, 0x9c30d539, 0x2af26013, + 0xc5d1b023, 0x286085f0, 0xca417918, 0xb8db38ef, 0x8e79dcb0, 0x603a180e, + 0x6c9e0e8b, 0xb01e8a3e, 0xd71577c1, 0xbd314b27, 0x78af2fda, 0x55605c60, + 0xe65525f3, 0xaa55ab94, 0x57489862, 0x63e81440, 0x55ca396a, 0x2aab10b6, + 0xb4cc5c34, 0x1141e8ce, 0xa15486af, 0x7c72e993, 0xb3ee1411, 0x636fbc2a, + 0x2ba9c55d, 0x741831f6, 0xce5c3e16, 0x9b87931e, 0xafd6ba33, 0x6c24cf5c, + 0x7a325381, 0x28958677, 0x3b8f4898, 0x6b4bb9af, 0xc4bfe81b, 0x66282193, + 0x61d809cc, 0xfb21a991, 0x487cac60, 0x5dec8032, 0xef845d5d, 0xe98575b1, + 0xdc262302, 0xeb651b88, 0x23893e81, 0xd396acc5, 0x0f6d6ff3, 0x83f44239, + 0x2e0b4482, 0xa4842004, 0x69c8f04a, 0x9e1f9b5e, 0x21c66842, 0xf6e96c9a, + 0x670c9c61, 0xabd388f0, 0x6a51a0d2, 0xd8542f68, 0x960fa728, 0xab5133a3, + 0x6eef0b6c, 0x137a3be4, 0xba3bf050, 0x7efb2a98, 0xa1f1651d, 0x39af0176, + 0x66ca593e, 0x82430e88, 0x8cee8619, 0x456f9fb4, 0x7d84a5c3, 0x3b8b5ebe, + 0xe06f75d8, 0x85c12073, 0x401a449f, 0x56c16aa6, 0x4ed3aa62, 0x363f7706, + 0x1bfedf72, 0x429b023d, 0x37d0d724, 0xd00a1248, 0xdb0fead3, 0x49f1c09b, + 0x075372c9, 0x80991b7b, 0x25d479d8, 0xf6e8def7, 0xe3fe501a, 0xb6794c3b, + 0x976ce0bd, 0x04c006ba, 0xc1a94fb6, 0x409f60c4, 0x5e5c9ec2, 0x196a2463, + 0x68fb6faf, 0x3e6c53b5, 0x1339b2eb, 0x3b52ec6f, 0x6dfc511f, 0x9b30952c, + 0xcc814544, 0xaf5ebd09, 0xbee3d004, 0xde334afd, 0x660f2807, 0x192e4bb3, + 0xc0cba857, 0x45c8740f, 0xd20b5f39, 0xb9d3fbdb, 0x5579c0bd, 0x1a60320a, + 0xd6a100c6, 0x402c7279, 0x679f25fe, 0xfb1fa3cc, 0x8ea5e9f8, 0xdb3222f8, + 0x3c7516df, 0xfd616b15, 0x2f501ec8, 0xad0552ab, 0x323db5fa, 0xfd238760, + 0x53317b48, 0x3e00df82, 0x9e5c57bb, 0xca6f8ca0, 0x1a87562e, 0xdf1769db, + 0xd542a8f6, 0x287effc3, 0xac6732c6, 0x8c4f5573, 0x695b27b0, 0xbbca58c8, + 0xe1ffa35d, 0xb8f011a0, 0x10fa3d98, 0xfd2183b8, 0x4afcb56c, 0x2dd1d35b, + 0x9a53e479, 0xb6f84565, 0xd28e49bc, 0x4bfb9790, 0xe1ddf2da, 0xa4cb7e33, + 0x62fb1341, 0xcee4c6e8, 0xef20cada, 0x36774c01, 0xd07e9efe, 0x2bf11fb4, + 0x95dbda4d, 0xae909198, 0xeaad8e71, 0x6b93d5a0, 0xd08ed1d0, 0xafc725e0, + 0x8e3c5b2f, 0x8e7594b7, 0x8ff6e2fb, 0xf2122b64, 0x8888b812, 0x900df01c, + 0x4fad5ea0, 0x688fc31c, 0xd1cff191, 0xb3a8c1ad, 0x2f2f2218, 0xbe0e1777, + 0xea752dfe, 0x8b021fa1, 0xe5a0cc0f, 0xb56f74e8, 0x18acf3d6, 0xce89e299, + 0xb4a84fe0, 0xfd13e0b7, 0x7cc43b81, 0xd2ada8d9, 0x165fa266, 0x80957705, + 0x93cc7314, 0x211a1477, 0xe6ad2065, 0x77b5fa86, 0xc75442f5, 0xfb9d35cf, + 0xebcdaf0c, 0x7b3e89a0, 0xd6411bd3, 0xae1e7e49, 0x00250e2d, 0x2071b35e, + 0x226800bb, 0x57b8e0af, 0x2464369b, 0xf009b91e, 0x5563911d, 0x59dfa6aa, + 0x78c14389, 0xd95a537f, 0x207d5ba2, 0x02e5b9c5, 0x83260376, 0x6295cfa9, + 0x11c81968, 0x4e734a41, 0xb3472dca, 0x7b14a94a, 0x1b510052, 0x9a532915, + 0xd60f573f, 0xbc9bc6e4, 0x2b60a476, 0x81e67400, 0x08ba6fb5, 0x571be91f, + 0xf296ec6b, 0x2a0dd915, 0xb6636521, 0xe7b9f9b6, 0xff34052e, 0xc5855664, + 0x53b02d5d, 0xa99f8fa1, 0x08ba4799, 0x6e85076a, 0x4b7a70e9, 0xb5b32944, + 0xdb75092e, 0xc4192623, 0xad6ea6b0, 0x49a7df7d, 0x9cee60b8, 0x8fedb266, + 0xecaa8c71, 0x699a17ff, 0x5664526c, 0xc2b19ee1, 0x193602a5, 0x75094c29, + 0xa0591340, 0xe4183a3e, 0x3f54989a, 0x5b429d65, 0x6b8fe4d6, 0x99f73fd6, + 0xa1d29c07, 0xefe830f5, 0x4d2d38e6, 0xf0255dc1, 0x4cdd2086, 0x8470eb26, + 0x6382e9c6, 0x021ecc5e, 0x09686b3f, 0x3ebaefc9, 0x3c971814, 0x6b6a70a1, + 0x687f3584, 0x52a0e286, 0xb79c5305, 0xaa500737, 0x3e07841c, 0x7fdeae5c, + 0x8e7d44ec, 0x5716f2b8, 0xb03ada37, 0xf0500c0d, 0xf01c1f04, 0x0200b3ff, + 0xae0cf51a, 0x3cb574b2, 0x25837a58, 0xdc0921bd, 0xd19113f9, 0x7ca92ff6, + 0x94324773, 0x22f54701, 0x3ae5e581, 0x37c2dadc, 0xc8b57634, 0x9af3dda7, + 0xa9446146, 0x0fd0030e, 0xecc8c73e, 0xa4751e41, 0xe238cd99, 0x3bea0e2f, + 0x3280bba1, 0x183eb331, 0x4e548b38, 0x4f6db908, 0x6f420d03, 0xf60a04bf, + 0x2cb81290, 0x24977c79, 0x5679b072, 0xbcaf89af, 0xde9a771f, 0xd9930810, + 0xb38bae12, 0xdccf3f2e, 0x5512721f, 0x2e6b7124, 0x501adde6, 0x9f84cd87, + 0x7a584718, 0x7408da17, 0xbc9f9abc, 0xe94b7d8c, 0xec7aec3a, 0xdb851dfa, + 0x63094366, 0xc464c3d2, 0xef1c1847, 0x3215d908, 0xdd433b37, 0x24c2ba16, + 0x12a14d43, 0x2a65c451, 0x50940002, 0x133ae4dd, 0x71dff89e, 0x10314e55, + 0x81ac77d6, 0x5f11199b, 0x043556f1, 0xd7a3c76b, 0x3c11183b, 0x5924a509, + 0xf28fe6ed, 0x97f1fbfa, 0x9ebabf2c, 0x1e153c6e, 0x86e34570, 0xeae96fb1, + 0x860e5e0a, 0x5a3e2ab3, 0x771fe71c, 0x4e3d06fa, 0x2965dcb9, 0x99e71d0f, + 0x803e89d6, 0x5266c825, 0x2e4cc978, 0x9c10b36a, 0xc6150eba, 0x94e2ea78, + 0xa5fc3c53, 0x1e0a2df4, 0xf2f74ea7, 0x361d2b3d, 0x1939260f, 0x19c27960, + 0x5223a708, 0xf71312b6, 0xebadfe6e, 0xeac31f66, 0xe3bc4595, 0xa67bc883, + 0xb17f37d1, 0x018cff28, 0xc332ddef, 0xbe6c5aa5, 0x65582185, 0x68ab9802, + 0xeecea50f, 0xdb2f953b, 0x2aef7dad, 0x5b6e2f84, 0x1521b628, 0x29076170, + 0xecdd4775, 0x619f1510, 0x13cca830, 0xeb61bd96, 0x0334fe1e, 0xaa0363cf, + 0xb5735c90, 0x4c70a239, 0xd59e9e0b, 0xcbaade14, 0xeecc86bc, 0x60622ca7, + 0x9cab5cab, 0xb2f3846e, 0x648b1eaf, 0x19bdf0ca, 0xa02369b9, 0x655abb50, + 0x40685a32, 0x3c2ab4b3, 0x319ee9d5, 0xc021b8f7, 0x9b540b19, 0x875fa099, + 0x95f7997e, 0x623d7da8, 0xf837889a, 0x97e32d77, 0x11ed935f, 0x16681281, + 0x0e358829, 0xc7e61fd6, 0x96dedfa1, 0x7858ba99, 0x57f584a5, 0x1b227263, + 0x9b83c3ff, 0x1ac24696, 0xcdb30aeb, 0x532e3054, 0x8fd948e4, 0x6dbc3128, + 0x58ebf2ef, 0x34c6ffea, 0xfe28ed61, 0xee7c3c73, 0x5d4a14d9, 0xe864b7e3, + 0x42105d14, 0x203e13e0, 0x45eee2b6, 0xa3aaabea, 0xdb6c4f15, 0xfacb4fd0, + 0xc742f442, 0xef6abbb5, 0x654f3b1d, 0x41cd2105, 0xd81e799e, 0x86854dc7, + 0xe44b476a, 0x3d816250, 0xcf62a1f2, 0x5b8d2646, 0xfc8883a0, 0xc1c7b6a3, + 0x7f1524c3, 0x69cb7492, 0x47848a0b, 0x5692b285, 0x095bbf00, 0xad19489d, + 0x1462b174, 0x23820e00, 0x58428d2a, 0x0c55f5ea, 0x1dadf43e, 0x233f7061, + 0x3372f092, 0x8d937e41, 0xd65fecf1, 0x6c223bdb, 0x7cde3759, 0xcbee7460, + 0x4085f2a7, 0xce77326e, 0xa6078084, 0x19f8509e, 0xe8efd855, 0x61d99735, + 0xa969a7aa, 0xc50c06c2, 0x5a04abfc, 0x800bcadc, 0x9e447a2e, 0xc3453484, + 0xfdd56705, 0x0e1e9ec9, 0xdb73dbd3, 0x105588cd, 0x675fda79, 0xe3674340, + 0xc5c43465, 0x713e38d8, 0x3d28f89e, 0xf16dff20, 0x153e21e7, 0x8fb03d4a, + 0xe6e39f2b, 0xdb83adf7, 0xe93d5a68, 0x948140f7, 0xf64c261c, 0x94692934, + 0x411520f7, 0x7602d4f7, 0xbcf46b2e, 0xd4a20068, 0xd4082471, 0x3320f46a, + 0x43b7d4b7, 0x500061af, 0x1e39f62e, 0x97244546, 0x14214f74, 0xbf8b8840, + 0x4d95fc1d, 0x96b591af, 0x70f4ddd3, 0x66a02f45, 0xbfbc09ec, 0x03bd9785, + 0x7fac6dd0, 0x31cb8504, 0x96eb27b3, 0x55fd3941, 0xda2547e6, 0xabca0a9a, + 0x28507825, 0x530429f4, 0x0a2c86da, 0xe9b66dfb, 0x68dc1462, 0xd7486900, + 0x680ec0a4, 0x27a18dee, 0x4f3ffea2, 0xe887ad8c, 0xb58ce006, 0x7af4d6b6, + 0xaace1e7c, 0xd3375fec, 0xce78a399, 0x406b2a42, 0x20fe9e35, 0xd9f385b9, + 0xee39d7ab, 0x3b124e8b, 0x1dc9faf7, 0x4b6d1856, 0x26a36631, 0xeae397b2, + 0x3a6efa74, 0xdd5b4332, 0x6841e7f7, 0xca7820fb, 0xfb0af54e, 0xd8feb397, + 0x454056ac, 0xba489527, 0x55533a3a, 0x20838d87, 0xfe6ba9b7, 0xd096954b, + 0x55a867bc, 0xa1159a58, 0xcca92963, 0x99e1db33, 0xa62a4a56, 0x3f3125f9, + 0x5ef47e1c, 0x9029317c, 0xfdf8e802, 0x04272f70, 0x80bb155c, 0x05282ce3, + 0x95c11548, 0xe4c66d22, 0x48c1133f, 0xc70f86dc, 0x07f9c9ee, 0x41041f0f, + 0x404779a4, 0x5d886e17, 0x325f51eb, 0xd59bc0d1, 0xf2bcc18f, 0x41113564, + 0x257b7834, 0x602a9c60, 0xdff8e8a3, 0x1f636c1b, 0x0e12b4c2, 0x02e1329e, + 0xaf664fd1, 0xcad18115, 0x6b2395e0, 0x333e92e1, 0x3b240b62, 0xeebeb922, + 0x85b2a20e, 0xe6ba0d99, 0xde720c8c, 0x2da2f728, 0xd0127845, 0x95b794fd, + 0x647d0862, 0xe7ccf5f0, 0x5449a36f, 0x877d48fa, 0xc39dfd27, 0xf33e8d1e, + 0x0a476341, 0x992eff74, 0x3a6f6eab, 0xf4f8fd37, 0xa812dc60, 0xa1ebddf8, + 0x991be14c, 0xdb6e6b0d, 0xc67b5510, 0x6d672c37, 0x2765d43b, 0xdcd0e804, + 0xf1290dc7, 0xcc00ffa3, 0xb5390f92, 0x690fed0b, 0x667b9ffb, 0xcedb7d9c, + 0xa091cf0b, 0xd9155ea3, 0xbb132f88, 0x515bad24, 0x7b9479bf, 0x763bd6eb, + 0x37392eb3, 0xcc115979, 0x8026e297, 0xf42e312d, 0x6842ada7, 0xc66a2b3b, + 0x12754ccc, 0x782ef11c, 0x6a124237, 0xb79251e7, 0x06a1bbe6, 0x4bfb6350, + 0x1a6b1018, 0x11caedfa, 0x3d25bdd8, 0xe2e1c3c9, 0x44421659, 0x0a121386, + 0xd90cec6e, 0xd5abea2a, 0x64af674e, 0xda86a85f, 0xbebfe988, 0x64e4c3fe, + 0x9dbc8057, 0xf0f7c086, 0x60787bf8, 0x6003604d, 0xd1fd8346, 0xf6381fb0, + 0x7745ae04, 0xd736fccc, 0x83426b33, 0xf01eab71, 0xb0804187, 0x3c005e5f, + 0x77a057be, 0xbde8ae24, 0x55464299, 0xbf582e61, 0x4e58f48f, 0xf2ddfda2, + 0xf474ef38, 0x8789bdc2, 0x5366f9c3, 0xc8b38e74, 0xb475f255, 0x46fcd9b9, + 0x7aeb2661, 0x8b1ddf84, 0x846a0e79, 0x915f95e2, 0x466e598e, 0x20b45770, + 0x8cd55591, 0xc902de4c, 0xb90bace1, 0xbb8205d0, 0x11a86248, 0x7574a99e, + 0xb77f19b6, 0xe0a9dc09, 0x662d09a1, 0xc4324633, 0xe85a1f02, 0x09f0be8c, + 0x4a99a025, 0x1d6efe10, 0x1ab93d1d, 0x0ba5a4df, 0xa186f20f, 0x2868f169, + 0xdcb7da83, 0x573906fe, 0xa1e2ce9b, 0x4fcd7f52, 0x50115e01, 0xa70683fa, + 0xa002b5c4, 0x0de6d027, 0x9af88c27, 0x773f8641, 0xc3604c06, 0x61a806b5, + 0xf0177a28, 0xc0f586e0, 0x006058aa, 0x30dc7d62, 0x11e69ed7, 0x2338ea63, + 0x53c2dd94, 0xc2c21634, 0xbbcbee56, 0x90bcb6de, 0xebfc7da1, 0xce591d76, + 0x6f05e409, 0x4b7c0188, 0x39720a3d, 0x7c927c24, 0x86e3725f, 0x724d9db9, + 0x1ac15bb4, 0xd39eb8fc, 0xed545578, 0x08fca5b5, 0xd83d7cd3, 0x4dad0fc4, + 0x1e50ef5e, 0xb161e6f8, 0xa28514d9, 0x6c51133c, 0x6fd5c7e7, 0x56e14ec4, + 0x362abfce, 0xddc6c837, 0xd79a3234, 0x92638212, 0x670efa8e, 0x406000e0, + 0x3a39ce37, 0xd3faf5cf, 0xabc27737, 0x5ac52d1b, 0x5cb0679e, 0x4fa33742, + 0xd3822740, 0x99bc9bbe, 0xd5118e9d, 0xbf0f7315, 0xd62d1c7e, 0xc700c47b, + 0xb78c1b6b, 0x21a19045, 0xb26eb1be, 0x6a366eb4, 0x5748ab2f, 0xbc946e79, + 0xc6a376d2, 0x6549c2c8, 0x530ff8ee, 0x468dde7d, 0xd5730a1d, 0x4cd04dc6, + 0x2939bbdb, 0xa9ba4650, 0xac9526e8, 0xbe5ee304, 0xa1fad5f0, 0x6a2d519a, + 0x63ef8ce2, 0x9a86ee22, 0xc089c2b8, 0x43242ef6, 0xa51e03aa, 0x9cf2d0a4, + 0x83c061ba, 0x9be96a4d, 0x8fe51550, 0xba645bd6, 0x2826a2f9, 0xa73a3ae1, + 0x4ba99586, 0xef5562e9, 0xc72fefd3, 0xf752f7da, 0x3f046f69, 0x77fa0a59, + 0x80e4a915, 0x87b08601, 0x9b09e6ad, 0x3b3ee593, 0xe990fd5a, 0x9e34d797, + 0x2cf0b7d9, 0x022b8b51, 0x96d5ac3a, 0x017da67d, 0xd1cf3ed6, 0x7c7d2d28, + 0x1f9f25cf, 0xadf2b89b, 0x5ad6b472, 0x5a88f54c, 0xe029ac71, 0xe019a5e6, + 0x47b0acfd, 0xed93fa9b, 0xe8d3c48d, 0x283b57cc, 0xf8d56629, 0x79132e28, + 0x785f0191, 0xed756055, 0xf7960e44, 0xe3d35e8c, 0x15056dd4, 0x88f46dba, + 0x03a16125, 0x0564f0bd, 0xc3eb9e15, 0x3c9057a2, 0x97271aec, 0xa93a072a, + 0x1b3f6d9b, 0x1e6321f5, 0xf59c66fb, 0x26dcf319, 0x7533d928, 0xb155fdf5, + 0x03563482, 0x8aba3cbb, 0x28517711, 0xc20ad9f8, 0xabcc5167, 0xccad925f, + 0x4de81751, 0x3830dc8e, 0x379d5862, 0x9320f991, 0xea7a90c2, 0xfb3e7bce, + 0x5121ce64, 0x774fbe32, 0xa8b6e37e, 0xc3293d46, 0x48de5369, 0x6413e680, + 0xa2ae0810, 0xdd6db224, 0x69852dfd, 0x09072166, 0xb39a460a, 0x6445c0dd, + 0x586cdecf, 0x1c20c8ae, 0x5bbef7dd, 0x1b588d40, 0xccd2017f, 0x6bb4e3bb, + 0xdda26a7e, 0x3a59ff45, 0x3e350a44, 0xbcb4cdd5, 0x72eacea8, 0xfa6484bb, + 0x8d6612ae, 0xbf3c6f47, 0xd29be463, 0x542f5d9e, 0xaec2771b, 0xf64e6370, + 0x740e0d8d, 0xe75b1357, 0xf8721671, 0xaf537d5d, 0x4040cb08, 0x4eb4e2cc, + 0x34d2466a, 0x0115af84, 0xe1b00428, 0x95983a1d, 0x06b89fb4, 0xce6ea048, + 0x6f3f3b82, 0x3520ab82, 0x011a1d4b, 0x277227f8, 0x611560b1, 0xe7933fdc, + 0xbb3a792b, 0x344525bd, 0xa08839e1, 0x51ce794b, 0x2f32c9b7, 0xa01fbac9, + 0xe01cc87e, 0xbcc7d1f6, 0xcf0111c3, 0xa1e8aac7, 0x1a908749, 0xd44fbd9a, + 0xd0dadecb, 0xd50ada38, 0x0339c32a, 0xc6913667, 0x8df9317c, 0xe0b12b4f, + 0xf79e59b7, 0x43f5bb3a, 0xf2d519ff, 0x27d9459c, 0xbf97222c, 0x15e6fc2a, + 0x0f91fc71, 0x9b941525, 0xfae59361, 0xceb69ceb, 0xc2a86459, 0x12baa8d1, + 0xb6c1075e, 0xe3056a0c, 0x10d25065, 0xcb03a442, 0xe0ec6e0e, 0x1698db3b, + 0x4c98a0be, 0x3278e964, 0x9f1f9532, 0xe0d392df, 0xd3a0342b, 0x8971f21e, + 0x1b0a7441, 0x4ba3348c, 0xc5be7120, 0xc37632d8, 0xdf359f8d, 0x9b992f2e, + 0xe60b6f47, 0x0fe3f11d, 0xe54cda54, 0x1edad891, 0xce6279cf, 0xcd3e7e6f, + 0x1618b166, 0xfd2c1d05, 0x848fd2c5, 0xf6fb2299, 0xf523f357, 0xa6327623, + 0x93a83531, 0x56cccd02, 0xacf08162, 0x5a75ebb5, 0x6e163697, 0x88d273cc, + 0xde966292, 0x81b949d0, 0x4c50901b, 0x71c65614, 0xe6c6c7bd, 0x327a140a, + 0x45e1d006, 0xc3f27b9a, 0xc9aa53fd, 0x62a80f00, 0xbb25bfe2, 0x35bdd2f6, + 0x71126905, 0xb2040222, 0xb6cbcf7c, 0xcd769c2b, 0x53113ec0, 0x1640e3d3, + 0x38abbd60, 0x2547adf0, 0xba38209c, 0xf746ce76, 0x77afa1c5, 0x20756060, + 0x85cbfe4e, 0x8ae88dd8, 0x7aaaf9b0, 0x4cf9aa7e, 0x1948c25c, 0x02fb8a8c, + 0x01c36ae4, 0xd6ebe1f9, 0x90d4f869, 0xa65cdea0, 0x3f09252d, 0xc208e69f, + 0xb74e6132, 0xce77e25b, 0x578fdfe3, 0x3ac372e6, + ]; + + /** + * @type {Array.} + * @const + * @inner + */ + var C_ORIG = [ + 0x4f727068, 0x65616e42, 0x65686f6c, 0x64657253, 0x63727944, 0x6f756274, + ]; + + /** + * @param {Array.} lr + * @param {number} off + * @param {Array.} P + * @param {Array.} S + * @returns {Array.} + * @inner + */ + function _encipher(lr, off, P, S) { + // This is our bottleneck: 1714/1905 ticks / 90% - see profile.txt + var n, + l = lr[off], + r = lr[off + 1]; + l ^= P[0]; + + /* + for (var i=0, k=BLOWFISH_NUM_ROUNDS-2; i<=k;) + // Feistel substitution on left word + n = S[l >>> 24], + n += S[0x100 | ((l >> 16) & 0xff)], + n ^= S[0x200 | ((l >> 8) & 0xff)], + n += S[0x300 | (l & 0xff)], + r ^= n ^ P[++i], + // Feistel substitution on right word + n = S[r >>> 24], + n += S[0x100 | ((r >> 16) & 0xff)], + n ^= S[0x200 | ((r >> 8) & 0xff)], + n += S[0x300 | (r & 0xff)], + l ^= n ^ P[++i]; + */ + + //The following is an unrolled version of the above loop. + //Iteration 0 + n = S[l >>> 24]; + n += S[0x100 | ((l >> 16) & 0xff)]; + n ^= S[0x200 | ((l >> 8) & 0xff)]; + n += S[0x300 | (l & 0xff)]; + r ^= n ^ P[1]; + n = S[r >>> 24]; + n += S[0x100 | ((r >> 16) & 0xff)]; + n ^= S[0x200 | ((r >> 8) & 0xff)]; + n += S[0x300 | (r & 0xff)]; + l ^= n ^ P[2]; + //Iteration 1 + n = S[l >>> 24]; + n += S[0x100 | ((l >> 16) & 0xff)]; + n ^= S[0x200 | ((l >> 8) & 0xff)]; + n += S[0x300 | (l & 0xff)]; + r ^= n ^ P[3]; + n = S[r >>> 24]; + n += S[0x100 | ((r >> 16) & 0xff)]; + n ^= S[0x200 | ((r >> 8) & 0xff)]; + n += S[0x300 | (r & 0xff)]; + l ^= n ^ P[4]; + //Iteration 2 + n = S[l >>> 24]; + n += S[0x100 | ((l >> 16) & 0xff)]; + n ^= S[0x200 | ((l >> 8) & 0xff)]; + n += S[0x300 | (l & 0xff)]; + r ^= n ^ P[5]; + n = S[r >>> 24]; + n += S[0x100 | ((r >> 16) & 0xff)]; + n ^= S[0x200 | ((r >> 8) & 0xff)]; + n += S[0x300 | (r & 0xff)]; + l ^= n ^ P[6]; + //Iteration 3 + n = S[l >>> 24]; + n += S[0x100 | ((l >> 16) & 0xff)]; + n ^= S[0x200 | ((l >> 8) & 0xff)]; + n += S[0x300 | (l & 0xff)]; + r ^= n ^ P[7]; + n = S[r >>> 24]; + n += S[0x100 | ((r >> 16) & 0xff)]; + n ^= S[0x200 | ((r >> 8) & 0xff)]; + n += S[0x300 | (r & 0xff)]; + l ^= n ^ P[8]; + //Iteration 4 + n = S[l >>> 24]; + n += S[0x100 | ((l >> 16) & 0xff)]; + n ^= S[0x200 | ((l >> 8) & 0xff)]; + n += S[0x300 | (l & 0xff)]; + r ^= n ^ P[9]; + n = S[r >>> 24]; + n += S[0x100 | ((r >> 16) & 0xff)]; + n ^= S[0x200 | ((r >> 8) & 0xff)]; + n += S[0x300 | (r & 0xff)]; + l ^= n ^ P[10]; + //Iteration 5 + n = S[l >>> 24]; + n += S[0x100 | ((l >> 16) & 0xff)]; + n ^= S[0x200 | ((l >> 8) & 0xff)]; + n += S[0x300 | (l & 0xff)]; + r ^= n ^ P[11]; + n = S[r >>> 24]; + n += S[0x100 | ((r >> 16) & 0xff)]; + n ^= S[0x200 | ((r >> 8) & 0xff)]; + n += S[0x300 | (r & 0xff)]; + l ^= n ^ P[12]; + //Iteration 6 + n = S[l >>> 24]; + n += S[0x100 | ((l >> 16) & 0xff)]; + n ^= S[0x200 | ((l >> 8) & 0xff)]; + n += S[0x300 | (l & 0xff)]; + r ^= n ^ P[13]; + n = S[r >>> 24]; + n += S[0x100 | ((r >> 16) & 0xff)]; + n ^= S[0x200 | ((r >> 8) & 0xff)]; + n += S[0x300 | (r & 0xff)]; + l ^= n ^ P[14]; + //Iteration 7 + n = S[l >>> 24]; + n += S[0x100 | ((l >> 16) & 0xff)]; + n ^= S[0x200 | ((l >> 8) & 0xff)]; + n += S[0x300 | (l & 0xff)]; + r ^= n ^ P[15]; + n = S[r >>> 24]; + n += S[0x100 | ((r >> 16) & 0xff)]; + n ^= S[0x200 | ((r >> 8) & 0xff)]; + n += S[0x300 | (r & 0xff)]; + l ^= n ^ P[16]; + lr[off] = r ^ P[BLOWFISH_NUM_ROUNDS + 1]; + lr[off + 1] = l; + return lr; + } + + /** + * @param {Array.} data + * @param {number} offp + * @returns {{key: number, offp: number}} + * @inner + */ + function _streamtoword(data, offp) { + for (var i = 0, word = 0; i < 4; ++i) + (word = (word << 8) | (data[offp] & 0xff)), + (offp = (offp + 1) % data.length); + return { + key: word, + offp: offp, + }; + } + + /** + * @param {Array.} key + * @param {Array.} P + * @param {Array.} S + * @inner + */ + function _key(key, P, S) { + var offset = 0, + lr = [0, 0], + plen = P.length, + slen = S.length, + sw; + for (var i = 0; i < plen; i++) + (sw = _streamtoword(key, offset)), + (offset = sw.offp), + (P[i] = P[i] ^ sw.key); + for (i = 0; i < plen; i += 2) + (lr = _encipher(lr, 0, P, S)), (P[i] = lr[0]), (P[i + 1] = lr[1]); + for (i = 0; i < slen; i += 2) + (lr = _encipher(lr, 0, P, S)), (S[i] = lr[0]), (S[i + 1] = lr[1]); + } + + /** + * Expensive key schedule Blowfish. + * @param {Array.} data + * @param {Array.} key + * @param {Array.} P + * @param {Array.} S + * @inner + */ + function _ekskey(data, key, P, S) { + var offp = 0, + lr = [0, 0], + plen = P.length, + slen = S.length, + sw; + for (var i = 0; i < plen; i++) + (sw = _streamtoword(key, offp)), + (offp = sw.offp), + (P[i] = P[i] ^ sw.key); + offp = 0; + for (i = 0; i < plen; i += 2) + (sw = _streamtoword(data, offp)), + (offp = sw.offp), + (lr[0] ^= sw.key), + (sw = _streamtoword(data, offp)), + (offp = sw.offp), + (lr[1] ^= sw.key), + (lr = _encipher(lr, 0, P, S)), + (P[i] = lr[0]), + (P[i + 1] = lr[1]); + for (i = 0; i < slen; i += 2) + (sw = _streamtoword(data, offp)), + (offp = sw.offp), + (lr[0] ^= sw.key), + (sw = _streamtoword(data, offp)), + (offp = sw.offp), + (lr[1] ^= sw.key), + (lr = _encipher(lr, 0, P, S)), + (S[i] = lr[0]), + (S[i + 1] = lr[1]); + } + + /** + * Internaly crypts a string. + * @param {Array.} b Bytes to crypt + * @param {Array.} salt Salt bytes to use + * @param {number} rounds Number of rounds + * @param {function(Error, Array.=)=} callback Callback receiving the error, if any, and the resulting bytes. If + * omitted, the operation will be performed synchronously. + * @param {function(number)=} progressCallback Callback called with the current progress + * @returns {!Array.|undefined} Resulting bytes if callback has been omitted, otherwise `undefined` + * @inner + */ + function _crypt(b, salt, rounds, callback, progressCallback) { + var cdata = C_ORIG.slice(), + clen = cdata.length, + err; + + // Validate + if (rounds < 4 || rounds > 31) { + err = Error("Illegal number of rounds (4-31): " + rounds); + if (callback) { + nextTick(callback.bind(this, err)); + return; + } else throw err; + } + if (salt.length !== BCRYPT_SALT_LEN) { + err = Error( + "Illegal salt length: " + salt.length + " != " + BCRYPT_SALT_LEN, + ); + if (callback) { + nextTick(callback.bind(this, err)); + return; + } else throw err; + } + rounds = (1 << rounds) >>> 0; + var P, + S, + i = 0, + j; + + //Use typed arrays when available - huge speedup! + if (typeof Int32Array === "function") { + P = new Int32Array(P_ORIG); + S = new Int32Array(S_ORIG); + } else { + P = P_ORIG.slice(); + S = S_ORIG.slice(); + } + _ekskey(salt, b, P, S); + + /** + * Calcualtes the next round. + * @returns {Array.|undefined} Resulting array if callback has been omitted, otherwise `undefined` + * @inner + */ + function next() { + if (progressCallback) progressCallback(i / rounds); + if (i < rounds) { + var start = Date.now(); + for (; i < rounds; ) { + i = i + 1; + _key(b, P, S); + _key(salt, P, S); + if (Date.now() - start > MAX_EXECUTION_TIME) break; + } + } else { + for (i = 0; i < 64; i++) + for (j = 0; j < clen >> 1; j++) _encipher(cdata, j << 1, P, S); + var ret = []; + for (i = 0; i < clen; i++) + ret.push(((cdata[i] >> 24) & 0xff) >>> 0), + ret.push(((cdata[i] >> 16) & 0xff) >>> 0), + ret.push(((cdata[i] >> 8) & 0xff) >>> 0), + ret.push((cdata[i] & 0xff) >>> 0); + if (callback) { + callback(null, ret); + return; + } else return ret; + } + if (callback) nextTick(next); + } + + // Async + if (typeof callback !== "undefined") { + next(); + + // Sync + } else { + var res; + while (true) + if (typeof (res = next()) !== "undefined") return res || []; + } + } + + /** + * Internally hashes a password. + * @param {string} password Password to hash + * @param {?string} salt Salt to use, actually never null + * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting hash. If omitted, + * hashing is performed synchronously. + * @param {function(number)=} progressCallback Callback called with the current progress + * @returns {string|undefined} Resulting hash if callback has been omitted, otherwise `undefined` + * @inner + */ + function _hash(password, salt, callback, progressCallback) { + var err; + if (typeof password !== "string" || typeof salt !== "string") { + err = Error("Invalid string / salt: Not a string"); + if (callback) { + nextTick(callback.bind(this, err)); + return; + } else throw err; + } + + // Validate the salt + var minor, offset; + if (salt.charAt(0) !== "$" || salt.charAt(1) !== "2") { + err = Error("Invalid salt version: " + salt.substring(0, 2)); + if (callback) { + nextTick(callback.bind(this, err)); + return; + } else throw err; + } + if (salt.charAt(2) === "$") + (minor = String.fromCharCode(0)), (offset = 3); + else { + minor = salt.charAt(2); + if ( + (minor !== "a" && minor !== "b" && minor !== "y") || + salt.charAt(3) !== "$" + ) { + err = Error("Invalid salt revision: " + salt.substring(2, 4)); + if (callback) { + nextTick(callback.bind(this, err)); + return; + } else throw err; + } + offset = 4; + } + + // Extract number of rounds + if (salt.charAt(offset + 2) > "$") { + err = Error("Missing salt rounds"); + if (callback) { + nextTick(callback.bind(this, err)); + return; + } else throw err; + } + var r1 = parseInt(salt.substring(offset, offset + 1), 10) * 10, + r2 = parseInt(salt.substring(offset + 1, offset + 2), 10), + rounds = r1 + r2, + real_salt = salt.substring(offset + 3, offset + 25); + password += minor >= "a" ? "\x00" : ""; + var passwordb = utf8Array(password), + saltb = base64_decode(real_salt, BCRYPT_SALT_LEN); + + /** + * Finishes hashing. + * @param {Array.} bytes Byte array + * @returns {string} + * @inner + */ + function finish(bytes) { + var res = []; + res.push("$2"); + if (minor >= "a") res.push(minor); + res.push("$"); + if (rounds < 10) res.push("0"); + res.push(rounds.toString()); + res.push("$"); + res.push(base64_encode(saltb, saltb.length)); + res.push(base64_encode(bytes, C_ORIG.length * 4 - 1)); + return res.join(""); + } + + // Sync + if (typeof callback == "undefined") + return finish(_crypt(passwordb, saltb, rounds)); + // Async + else { + _crypt( + passwordb, + saltb, + rounds, + function (err, bytes) { + if (err) callback(err, null); + else callback(null, finish(bytes)); + }, + progressCallback, + ); + } + } + + /** + * Encodes a byte array to base64 with up to len bytes of input, using the custom bcrypt alphabet. + * @function + * @param {!Array.} bytes Byte array + * @param {number} length Maximum input length + * @returns {string} + */ + function encodeBase64(bytes, length) { + return base64_encode(bytes, length); + } + + /** + * Decodes a base64 encoded string to up to len bytes of output, using the custom bcrypt alphabet. + * @function + * @param {string} string String to decode + * @param {number} length Maximum output length + * @returns {!Array.} + */ + function decodeBase64(string, length) { + return base64_decode(string, length); + } + var _default = (_exports.default = { + setRandomFallback, + genSaltSync, + genSalt, + hashSync, + hash, + compareSync, + compare, + getRounds, + getSalt, + truncates, + encodeBase64, + decodeBase64, + }); + }, +); diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index 912bb1e0..3efcb7fd 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -1,5 +1,14 @@ // Config Generator for ntfy // +// Warning, AI code +// ---------------- +// This code is entirely AI generated, but this very comment is not. Phil wrote this. Hi! +// I felt like the Config Generator was a great feature to have, but it would have taken me forever +// to write this code without AI. I reviewed the code manually, and it doesn't do anything dangerous. +// It's not the greatest code, but it works well enough to deliver value, and that's what it's all about. +// +// End of human comment. ;) +// // How it works // ------------ // The generator is a modal with a left panel (form inputs) and a right panel (live output). @@ -27,7 +36,8 @@ // if the user's access/login settings no longer match "open" or "private". // // Event listeners are grouped into setup functions (setupModalEvents, setupAuthEvents, -// setupServerTypeEvents, setupFormListeners, setupWebPushEvents) called from initGenerator(). +// setupServerTypeEvents, setupUnifiedPushEvents, setupFormListeners, setupWebPushEvents) +// called from initGenerator(). // A general listener on all inputs calls the update cycle. Specific listeners handle cleanup // logic, e.g. unchecking auth resets all auth-related fields and provisioned rows. // @@ -68,7 +78,10 @@ // Sets upstream-base-url to "https://ntfy.sh" when Yes, clears when No. // // UnifiedPush (Yes / No) — radio pair -// When Yes, prepends a "*:up*:write-only" ACL entry at collection time. +// When Yes, enables auth (if not already on) and adds a disabled "*:up*:write-only" +// ACL row to the Users tab. The row's fields are grayed out and non-editable. It is +// collected like any other ACL row. Clicking its [x] removes the row and toggles +// UnifiedPush back to No. // // Database type (SQLite / PostgreSQL) // When PostgreSQL is selected: @@ -86,1079 +99,1211 @@ // The prefill is skipped if the user has already typed (or cleared) the field // (tracked via data-cleared attribute). // -(function () { - "use strict"; +(function() { + "use strict"; - const CONFIG = [ - { key: "base-url", env: "NTFY_BASE_URL", section: "basic" }, - { key: "behind-proxy", env: "NTFY_BEHIND_PROXY", section: "basic", type: "bool" }, - { key: "database-url", env: "NTFY_DATABASE_URL", section: "database" }, - { key: "auth-file", env: "NTFY_AUTH_FILE", section: "auth" }, - { key: "auth-default-access", env: "NTFY_AUTH_DEFAULT_ACCESS", section: "auth", def: "read-write" }, - { key: "enable-login", env: "NTFY_ENABLE_LOGIN", section: "auth", type: "bool" }, - { key: "require-login", env: "NTFY_REQUIRE_LOGIN", section: "auth", type: "bool" }, - { key: "enable-signup", env: "NTFY_ENABLE_SIGNUP", section: "auth", type: "bool" }, - { key: "attachment-cache-dir", env: "NTFY_ATTACHMENT_CACHE_DIR", section: "attach" }, - { key: "attachment-file-size-limit", env: "NTFY_ATTACHMENT_FILE_SIZE_LIMIT", section: "attach", def: "15M" }, - { key: "attachment-total-size-limit", env: "NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT", section: "attach", def: "5G" }, - { key: "attachment-expiry-duration", env: "NTFY_ATTACHMENT_EXPIRY_DURATION", section: "attach", def: "3h" }, - { key: "cache-file", env: "NTFY_CACHE_FILE", section: "cache" }, - { key: "cache-duration", env: "NTFY_CACHE_DURATION", section: "cache", def: "12h" }, - { key: "web-push-public-key", env: "NTFY_WEB_PUSH_PUBLIC_KEY", section: "webpush" }, - { key: "web-push-private-key", env: "NTFY_WEB_PUSH_PRIVATE_KEY", section: "webpush" }, - { key: "web-push-file", env: "NTFY_WEB_PUSH_FILE", section: "webpush" }, - { key: "web-push-email-address", env: "NTFY_WEB_PUSH_EMAIL_ADDRESS", section: "webpush" }, - { key: "smtp-sender-addr", env: "NTFY_SMTP_SENDER_ADDR", section: "smtp-out" }, - { key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" }, - { key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" }, - { key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, - { key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" }, - { key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" }, - { key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, - { key: "upstream-base-url", env: "NTFY_UPSTREAM_BASE_URL", section: "upstream" }, + const CONFIG = [ + { key: "base-url", env: "NTFY_BASE_URL", section: "basic" }, + { key: "behind-proxy", env: "NTFY_BEHIND_PROXY", section: "basic", type: "bool" }, + { key: "database-url", env: "NTFY_DATABASE_URL", section: "database" }, + { key: "auth-file", env: "NTFY_AUTH_FILE", section: "auth" }, + { key: "auth-default-access", env: "NTFY_AUTH_DEFAULT_ACCESS", section: "auth", def: "read-write" }, + { key: "enable-login", env: "NTFY_ENABLE_LOGIN", section: "auth", type: "bool" }, + { key: "require-login", env: "NTFY_REQUIRE_LOGIN", section: "auth", type: "bool" }, + { key: "enable-signup", env: "NTFY_ENABLE_SIGNUP", section: "auth", type: "bool" }, + { key: "attachment-cache-dir", env: "NTFY_ATTACHMENT_CACHE_DIR", section: "attach" }, + { key: "attachment-file-size-limit", env: "NTFY_ATTACHMENT_FILE_SIZE_LIMIT", section: "attach", def: "15M" }, + { key: "attachment-total-size-limit", env: "NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT", section: "attach", def: "5G" }, + { key: "attachment-expiry-duration", env: "NTFY_ATTACHMENT_EXPIRY_DURATION", section: "attach", def: "3h" }, + { key: "cache-file", env: "NTFY_CACHE_FILE", section: "cache" }, + { key: "cache-duration", env: "NTFY_CACHE_DURATION", section: "cache", def: "12h" }, + { key: "web-push-public-key", env: "NTFY_WEB_PUSH_PUBLIC_KEY", section: "webpush" }, + { key: "web-push-private-key", env: "NTFY_WEB_PUSH_PRIVATE_KEY", section: "webpush" }, + { key: "web-push-file", env: "NTFY_WEB_PUSH_FILE", section: "webpush" }, + { key: "web-push-email-address", env: "NTFY_WEB_PUSH_EMAIL_ADDRESS", section: "webpush" }, + { key: "smtp-sender-addr", env: "NTFY_SMTP_SENDER_ADDR", section: "smtp-out" }, + { key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" }, + { key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" }, + { key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, + { key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" }, + { key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" }, + { key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, + { key: "upstream-base-url", env: "NTFY_UPSTREAM_BASE_URL", section: "upstream" } + ]; + + // Feature checkbox → nav tab ID + const NAV_MAP = { + "cg-feat-auth": "cg-nav-auth", + "cg-feat-cache": "cg-nav-cache", + "cg-feat-attach": "cg-nav-attach", + "cg-feat-webpush": "cg-nav-webpush" + }; + + const SECTION_COMMENTS = { + basic: "# Server", + database: "# Database", + auth: "# Access control", + attach: "# Attachments", + cache: "# Message cache", + webpush: "# Web push", + "smtp-out": "# Email notifications (outgoing)", + "smtp-in": "# Email publishing (incoming)", + upstream: "# Upstream" + }; + + const durationRegex = /^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$/i; + const sizeRegex = /^(\d+)([tgmkb])?$/i; + + // --- DOM cache --- + + function cacheElements(modal) { + return { + modal, + authCheckbox: modal.querySelector("#cg-feat-auth"), + cacheCheckbox: modal.querySelector("#cg-feat-cache"), + attachCheckbox: modal.querySelector("#cg-feat-attach"), + webpushCheckbox: modal.querySelector("#cg-feat-webpush"), + smtpOutCheckbox: modal.querySelector("#cg-feat-smtp-out"), + smtpInCheckbox: modal.querySelector("#cg-feat-smtp-in"), + accessSelect: modal.querySelector("#cg-default-access-select"), + accessHidden: modal.querySelector("input[type=\"hidden\"][data-key=\"auth-default-access\"]"), + loginHidden: modal.querySelector("#cg-enable-login-hidden"), + requireLoginHidden: modal.querySelector("#cg-require-login-hidden"), + signupHidden: modal.querySelector("#cg-enable-signup-hidden"), + proxyCheckbox: modal.querySelector("#cg-behind-proxy"), + dbStep: modal.querySelector("#cg-wizard-db"), + navDb: modal.querySelector("#cg-nav-database"), + navEmail: modal.querySelector("#cg-nav-email"), + emailOutSection: modal.querySelector("#cg-email-out-section"), + emailInSection: modal.querySelector("#cg-email-in-section"), + codeEl: modal.querySelector("#cg-code"), + warningsEl: modal.querySelector("#cg-warnings") + }; + } + + // --- Collect values --- + + function collectValues(els) { + const { modal } = els; + const values = {}; + + CONFIG.forEach((c) => { + const el = modal.querySelector(`[data-key="${c.key}"]`); + if (!el) return; + + // Skip fields in hidden panels (feature not enabled) + const panel = el.closest(".cg-panel"); + if (panel) { + // Panel hidden directly + if (panel.style.display === "none" || panel.classList.contains("cg-hidden")) return; + // Panel with a nav tab that is hidden (feature not enabled) + if (!panel.classList.contains("active")) { + const panelId = panel.id; + const navTab = modal.querySelector(`[data-panel="${panelId}"]`); + if (!navTab || navTab.classList.contains("cg-hidden")) return; + } + } + + // Skip file inputs replaced by PostgreSQL + if (el.dataset.pgDisabled) return; + + // Skip hidden individual fields or sections + let ancestor = el.parentElement; + while (ancestor && ancestor !== modal) { + if (ancestor.style.display === "none" || ancestor.classList.contains("cg-hidden")) return; + ancestor = ancestor.parentElement; + } + + let val; + if (c.type === "bool") { + if (el.checked) val = "true"; + } else { + val = el.value.trim(); + if (!val) return; + } + if (val && c.def && val === c.def) return; + if (val) values[c.key] = val; + }); + + // Provisioned users + const users = collectRepeatableRows(modal, ".cg-auth-user-row", (row) => { + const u = row.querySelector("[data-field=\"username\"]"); + const p = row.querySelector("[data-field=\"password\"]"); + const r = row.querySelector("[data-field=\"role\"]"); + if (u && p && u.value.trim() && p.value.trim()) { + return { username: u.value.trim(), password: p.value.trim(), role: r ? r.value : "user" }; + } + return null; + }); + if (users.length) values["_auth-users"] = users; + + // Provisioned ACLs + const acls = collectRepeatableRows(modal, ".cg-auth-acl-row", (row) => { + const u = row.querySelector("[data-field=\"username\"]"); + const t = row.querySelector("[data-field=\"topic\"]"); + const p = row.querySelector("[data-field=\"permission\"]"); + if (u && t && t.value.trim()) { + return { user: u.value.trim(), topic: t.value.trim(), permission: p ? p.value : "read-write" }; + } + return null; + }); + if (acls.length) values["_auth-acls"] = acls; + + // Provisioned tokens + const tokens = collectRepeatableRows(modal, ".cg-auth-token-row", (row) => { + const u = row.querySelector("[data-field=\"username\"]"); + const t = row.querySelector("[data-field=\"token\"]"); + const l = row.querySelector("[data-field=\"label\"]"); + if (u && t && u.value.trim() && t.value.trim()) { + return { user: u.value.trim(), token: t.value.trim(), label: l ? l.value.trim() : "" }; + } + return null; + }); + if (tokens.length) values["_auth-tokens"] = tokens; + + return values; + } + + function collectRepeatableRows(modal, selector, extractor) { + const results = []; + modal.querySelectorAll(selector).forEach((row) => { + const item = extractor(row); + if (item) results.push(item); + }); + return results; + } + + // --- Shared auth formatting --- + + const bcryptCache = {}; + + function hashPassword(username, password) { + if (password.startsWith("$2")) return password; // already a bcrypt hash + const cacheKey = username + "\0" + password; + if (bcryptCache[cacheKey]) return bcryptCache[cacheKey]; + const hash = (typeof bcrypt !== "undefined") ? bcrypt.hashSync(password, 10) : password; + bcryptCache[cacheKey] = hash; + return hash; + } + + function formatAuthUsers(values) { + if (!values["_auth-users"]) return null; + return values["_auth-users"].map((u) => `${u.username}:${hashPassword(u.username, u.password)}:${u.role}`); + } + + function formatAuthAcls(values) { + if (!values["_auth-acls"]) return null; + return values["_auth-acls"].map((a) => `${a.user || "*"}:${a.topic}:${a.permission}`); + } + + function formatAuthTokens(values) { + if (!values["_auth-tokens"]) return null; + return values["_auth-tokens"].map((t) => t.label ? `${t.user}:${t.token}:${t.label}` : `${t.user}:${t.token}`); + } + + // --- Output generators --- + + function generateServerYml(values) { + const lines = []; + let lastSection = ""; + let hadAuth = false; + + CONFIG.forEach((c) => { + if (!(c.key in values)) return; + if (c.section !== lastSection) { + if (lines.length) lines.push(""); + if (SECTION_COMMENTS[c.section]) lines.push(SECTION_COMMENTS[c.section]); + lastSection = c.section; + } + if (c.section === "auth") hadAuth = true; + const val = values[c.key]; + lines.push(c.type === "bool" ? `${c.key}: true` : `${c.key}: "${val}"`); + }); + + // Find insertion point for auth-users/auth-access/auth-tokens: + // right after the last "auth-" prefixed line, before enable-*/require-* lines + let authInsertIdx = lines.length; + if (hadAuth) { + for (let i = 0; i < lines.length; i++) { + if (lines[i] === "# Access control") { + // Find the last auth-* prefixed key in this section + let lastAuthKey = i; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].startsWith("# ")) break; + if (lines[j].startsWith("auth-")) lastAuthKey = j; + } + authInsertIdx = lastAuthKey + 1; + break; + } + } + } + + const authExtra = []; + const users = formatAuthUsers(values); + if (users) { + if (!hadAuth) { + authExtra.push(""); + authExtra.push("# Access control"); + hadAuth = true; + } + authExtra.push("auth-users:"); + users.forEach((entry) => authExtra.push(` - "${entry}"`)); + } + + const acls = formatAuthAcls(values); + if (acls) { + if (!hadAuth) { + authExtra.push(""); + authExtra.push("# Access control"); + hadAuth = true; + } + authExtra.push("auth-access:"); + acls.forEach((entry) => authExtra.push(` - "${entry}"`)); + } + + const tokens = formatAuthTokens(values); + if (tokens) { + if (!hadAuth) { + authExtra.push(""); + authExtra.push("# Access control"); + hadAuth = true; + } + authExtra.push("auth-tokens:"); + tokens.forEach((entry) => authExtra.push(` - "${entry}"`)); + } + + // Splice auth extras into the right position + if (authExtra.length) { + lines.splice(authInsertIdx, 0, ...authExtra); + } + + return lines.join("\n"); + } + + function generateDockerCompose(values) { + const lines = [ + "services:", + " ntfy:", + " image: binwiederhier/ntfy", + " command: serve", + " environment:" ]; - // Feature checkbox → nav tab ID - const NAV_MAP = { - "cg-feat-auth": "cg-nav-auth", - "cg-feat-cache": "cg-nav-cache", - "cg-feat-attach": "cg-nav-attach", - "cg-feat-webpush": "cg-nav-webpush", - }; + let hasDollarNote = false; + CONFIG.forEach((c) => { + if (!(c.key in values)) return; + let val = c.type === "bool" ? "true" : values[c.key]; + if (val.includes("$")) { + val = val.replace(/\$/g, "$$$$"); + hasDollarNote = true; + } + lines.push(` ${c.env}: "${val}"`); + }); - const SECTION_COMMENTS = { - basic: "# Server", - database: "# Database", - auth: "# Access control", - attach: "# Attachments", - cache: "# Message cache", - webpush: "# Web push", - "smtp-out": "# Email notifications (outgoing)", - "smtp-in": "# Email publishing (incoming)", - upstream: "# Upstream", - }; - - const durationRegex = /^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$/i; - const sizeRegex = /^(\d+)([tgmkb])?$/i; - - // --- DOM cache --- - - function cacheElements(modal) { - return { - modal, - authCheckbox: modal.querySelector("#cg-feat-auth"), - cacheCheckbox: modal.querySelector("#cg-feat-cache"), - attachCheckbox: modal.querySelector("#cg-feat-attach"), - webpushCheckbox: modal.querySelector("#cg-feat-webpush"), - smtpOutCheckbox: modal.querySelector("#cg-feat-smtp-out"), - smtpInCheckbox: modal.querySelector("#cg-feat-smtp-in"), - accessSelect: modal.querySelector("#cg-default-access-select"), - accessHidden: modal.querySelector('input[type="hidden"][data-key="auth-default-access"]'), - loginHidden: modal.querySelector("#cg-enable-login-hidden"), - requireLoginHidden: modal.querySelector("#cg-require-login-hidden"), - signupHidden: modal.querySelector("#cg-enable-signup-hidden"), - proxyCheckbox: modal.querySelector("#cg-behind-proxy"), - dbStep: modal.querySelector("#cg-wizard-db"), - navDb: modal.querySelector("#cg-nav-database"), - navEmail: modal.querySelector("#cg-nav-email"), - emailOutSection: modal.querySelector("#cg-email-out-section"), - emailInSection: modal.querySelector("#cg-email-in-section"), - codeEl: modal.querySelector("#cg-code"), - warningsEl: modal.querySelector("#cg-warnings"), - }; + const users = formatAuthUsers(values); + if (users) { + let usersVal = users.join(","); + usersVal = usersVal.replace(/\$/g, "$$$$"); + hasDollarNote = true; + lines.push(` NTFY_AUTH_USERS: "${usersVal}"`); } - // --- Collect values --- - - function collectValues(els) { - const { modal } = els; - const values = {}; - - CONFIG.forEach((c) => { - const el = modal.querySelector(`[data-key="${c.key}"]`); - if (!el) return; - - // Skip fields in hidden panels (feature not enabled) - const panel = el.closest(".cg-panel"); - if (panel) { - // Panel hidden directly - if (panel.style.display === "none" || panel.classList.contains("cg-hidden")) return; - // Panel with a nav tab that is hidden (feature not enabled) - if (!panel.classList.contains("active")) { - const panelId = panel.id; - const navTab = modal.querySelector(`[data-panel="${panelId}"]`); - if (!navTab || navTab.classList.contains("cg-hidden")) return; - } - } - - // Skip file inputs replaced by PostgreSQL - if (el.dataset.pgDisabled) return; - - // Skip hidden individual fields or sections - let ancestor = el.parentElement; - while (ancestor && ancestor !== modal) { - if (ancestor.style.display === "none" || ancestor.classList.contains("cg-hidden")) return; - ancestor = ancestor.parentElement; - } - - let val; - if (c.type === "bool") { - if (el.checked) val = "true"; - } else { - val = el.value.trim(); - if (!val) return; - } - if (val && c.def && val === c.def) return; - if (val) values[c.key] = val; - }); - - // Provisioned users - const users = collectRepeatableRows(modal, ".cg-auth-user-row", (row) => { - const u = row.querySelector('[data-field="username"]'); - const p = row.querySelector('[data-field="password"]'); - const r = row.querySelector('[data-field="role"]'); - if (u && p && u.value.trim() && p.value.trim()) { - return { username: u.value.trim(), password: p.value.trim(), role: r ? r.value : "user" }; - } - return null; - }); - if (users.length) values["_auth-users"] = users; - - // Provisioned ACLs - const acls = collectRepeatableRows(modal, ".cg-auth-acl-row", (row) => { - const u = row.querySelector('[data-field="username"]'); - const t = row.querySelector('[data-field="topic"]'); - const p = row.querySelector('[data-field="permission"]'); - if (u && t && t.value.trim()) { - return { user: u.value.trim(), topic: t.value.trim(), permission: p ? p.value : "read-write" }; - } - return null; - }); - if (acls.length) values["_auth-acls"] = acls; - - // Provisioned tokens - const tokens = collectRepeatableRows(modal, ".cg-auth-token-row", (row) => { - const u = row.querySelector('[data-field="username"]'); - const t = row.querySelector('[data-field="token"]'); - const l = row.querySelector('[data-field="label"]'); - if (u && t && u.value.trim() && t.value.trim()) { - return { user: u.value.trim(), token: t.value.trim(), label: l ? l.value.trim() : "" }; - } - return null; - }); - if (tokens.length) values["_auth-tokens"] = tokens; - - // UnifiedPush ACL - const upYes = modal.querySelector('input[name="cg-unifiedpush"][value="yes"]'); - if (upYes && upYes.checked) { - if (!values["_auth-acls"]) values["_auth-acls"] = []; - values["_auth-acls"].unshift({ user: "*", topic: "up*", permission: "write-only" }); - } - - return values; + const acls = formatAuthAcls(values); + if (acls) { + lines.push(` NTFY_AUTH_ACCESS: "${acls.join(",")}"`); } - function collectRepeatableRows(modal, selector, extractor) { - const results = []; - modal.querySelectorAll(selector).forEach((row) => { - const item = extractor(row); - if (item) results.push(item); - }); - return results; + const tokens = formatAuthTokens(values); + if (tokens) { + lines.push(` NTFY_AUTH_TOKENS: "${tokens.join(",")}"`); } - // --- Shared auth formatting --- - - function formatAuthUsers(values) { - if (!values["_auth-users"]) return null; - return values["_auth-users"].map((u) => `${u.username}:${u.password}:${u.role}`); + if (hasDollarNote) { + // Insert note after "environment:" line + const envIdx = lines.indexOf(" environment:"); + if (envIdx !== -1) { + lines.splice(envIdx + 1, 0, " # Note: $ is doubled to $$ for docker-compose"); + } } - function formatAuthAcls(values) { - if (!values["_auth-acls"]) return null; - return values["_auth-acls"].map((a) => `${a.user || "*"}:${a.topic}:${a.permission}`); + lines.push( + " volumes:", + " - /var/cache/ntfy:/var/cache/ntfy", + " - /etc/ntfy:/etc/ntfy", + " ports:", + " - \"80:80\"", + " restart: unless-stopped" + ); + + return lines.join("\n"); + } + + function generateEnvVars(values) { + const lines = []; + + CONFIG.forEach((c) => { + if (!(c.key in values)) return; + const val = c.type === "bool" ? "true" : values[c.key]; + const q = val.includes("$") ? "'" : "\""; + lines.push(`${c.env}=${q}${val}${q}`); + }); + + const users = formatAuthUsers(values); + if (users) { + const usersStr = users.join(","); + const q = usersStr.includes("$") ? "'" : "\""; + lines.push(`NTFY_AUTH_USERS=${q}${usersStr}${q}`); } - function formatAuthTokens(values) { - if (!values["_auth-tokens"]) return null; - return values["_auth-tokens"].map((t) => t.label ? `${t.user}:${t.token}:${t.label}` : `${t.user}:${t.token}`); + const acls = formatAuthAcls(values); + if (acls) { + lines.push(`NTFY_AUTH_ACCESS="${acls.join(",")}"`); } - // --- Output generators --- - - function generateServerYml(values) { - const lines = []; - let lastSection = ""; - let hadAuth = false; - - CONFIG.forEach((c) => { - if (!(c.key in values)) return; - if (c.section !== lastSection) { - if (lines.length) lines.push(""); - if (SECTION_COMMENTS[c.section]) lines.push(SECTION_COMMENTS[c.section]); - lastSection = c.section; - } - if (c.section === "auth") hadAuth = true; - const val = values[c.key]; - lines.push(c.type === "bool" ? `${c.key}: true` : `${c.key}: "${val}"`); - }); - - // Find where auth section ends to insert users/acls/tokens there - let authInsertIdx = lines.length; - if (hadAuth) { - for (let i = 0; i < lines.length; i++) { - if (lines[i] === "# Access control") { - // Find the end of this section (next section comment or end) - for (let j = i + 1; j < lines.length; j++) { - if (lines[j].startsWith("# ")) { authInsertIdx = j - 1; break; } - authInsertIdx = j + 1; - } - break; - } - } - } - - const authExtra = []; - const users = formatAuthUsers(values); - if (users) { - if (!hadAuth) { authExtra.push(""); authExtra.push("# Access control"); hadAuth = true; } - authExtra.push("auth-users:"); - users.forEach((entry) => authExtra.push(` - "${entry}"`)); - } - - const acls = formatAuthAcls(values); - if (acls) { - if (!hadAuth) { authExtra.push(""); authExtra.push("# Access control"); hadAuth = true; } - authExtra.push("auth-access:"); - acls.forEach((entry) => authExtra.push(` - "${entry}"`)); - } - - const tokens = formatAuthTokens(values); - if (tokens) { - if (!hadAuth) { authExtra.push(""); authExtra.push("# Access control"); hadAuth = true; } - authExtra.push("auth-tokens:"); - tokens.forEach((entry) => authExtra.push(` - "${entry}"`)); - } - - // Splice auth extras into the right position - if (authExtra.length) { - lines.splice(authInsertIdx, 0, ...authExtra); - } - - return lines.join("\n"); + const tokens = formatAuthTokens(values); + if (tokens) { + lines.push(`NTFY_AUTH_TOKENS="${tokens.join(",")}"`); } - function generateDockerCompose(values) { - const lines = [ - "services:", - " ntfy:", - " image: binwiederhier/ntfy", - " command: serve", - " environment:", - ]; + return lines.join("\n"); + } - CONFIG.forEach((c) => { - if (!(c.key in values)) return; - let val = c.type === "bool" ? "true" : values[c.key]; - if (val.includes("$")) { - val = val.replace(/\$/g, "$$$$"); - lines.push(" # Note: $ is doubled to $$ for docker-compose"); - } - lines.push(` ${c.env}: ${val}`); - }); + // --- Web Push VAPID key generation (P-256 ECDH) --- - const users = formatAuthUsers(values); - if (users) { - let usersVal = users.join(","); - usersVal = usersVal.replace(/\$/g, "$$$$"); - lines.push(" # Note: $ is doubled to $$ for docker-compose"); - lines.push(` NTFY_AUTH_USERS: ${usersVal}`); - } + function generateVAPIDKeys() { + return crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"] + ).then((keyPair) => { + return Promise.all([ + crypto.subtle.exportKey("raw", keyPair.publicKey), + crypto.subtle.exportKey("pkcs8", keyPair.privateKey) + ]); + }).then((keys) => { + const pubBytes = new Uint8Array(keys[0]); + const privPkcs8 = new Uint8Array(keys[1]); + // Extract raw 32-byte private key from PKCS#8 (last 32 bytes of the DER) + const privBytes = privPkcs8.slice(privPkcs8.length - 32); + return { + publicKey: arrayToBase64Url(pubBytes), + privateKey: arrayToBase64Url(privBytes) + }; + }); + } - const acls = formatAuthAcls(values); - if (acls) { - lines.push(` NTFY_AUTH_ACCESS: ${acls.join(",")}`); - } + function arrayToBase64Url(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } - const tokens = formatAuthTokens(values); - if (tokens) { - lines.push(` NTFY_AUTH_TOKENS: ${tokens.join(",")}`); - } + // --- Output + validation --- - lines.push( - " volumes:", - " - /var/cache/ntfy:/var/cache/ntfy", - " - /etc/ntfy:/etc/ntfy", - " ports:", - ' - "80:80"', - " restart: unless-stopped", - ); + function updateOutput(els) { + const { modal, codeEl, warningsEl } = els; + if (!codeEl) return; - return lines.join("\n"); + const values = collectValues(els); + const activeTab = modal.querySelector(".cg-output-tab.active"); + const format = activeTab ? activeTab.getAttribute("data-format") : "server-yml"; + + const hasValues = Object.keys(values).length > 0; + if (!hasValues) { + codeEl.innerHTML = "Configure options on the left to generate your config..."; + setHidden(warningsEl, true); + return; } - function generateEnvVars(values) { - const lines = []; - - CONFIG.forEach((c) => { - if (!(c.key in values)) return; - const val = c.type === "bool" ? "true" : values[c.key]; - const q = val.includes("$") ? "'" : '"'; - lines.push(`${c.env}=${q}${val}${q}`); - }); - - const users = formatAuthUsers(values); - if (users) { - const usersStr = users.join(","); - const q = usersStr.includes("$") ? "'" : '"'; - lines.push(`NTFY_AUTH_USERS=${q}${usersStr}${q}`); - } - - const acls = formatAuthAcls(values); - if (acls) { - lines.push(`NTFY_AUTH_ACCESS="${acls.join(",")}"`); - } - - const tokens = formatAuthTokens(values); - if (tokens) { - lines.push(`NTFY_AUTH_TOKENS="${tokens.join(",")}"`); - } - - return lines.join("\n"); - } - - // --- Web Push VAPID key generation (P-256 ECDH) --- - - function generateVAPIDKeys() { - return crypto.subtle.generateKey( - { name: "ECDH", namedCurve: "P-256" }, - true, - ["deriveBits"] - ).then((keyPair) => { - return Promise.all([ - crypto.subtle.exportKey("raw", keyPair.publicKey), - crypto.subtle.exportKey("pkcs8", keyPair.privateKey) - ]); - }).then((keys) => { - const pubBytes = new Uint8Array(keys[0]); - const privPkcs8 = new Uint8Array(keys[1]); - // Extract raw 32-byte private key from PKCS#8 (last 32 bytes of the DER) - const privBytes = privPkcs8.slice(privPkcs8.length - 32); - return { - publicKey: arrayToBase64Url(pubBytes), - privateKey: arrayToBase64Url(privBytes) - }; - }); - } - - function arrayToBase64Url(arr) { - let str = ""; - for (let i = 0; i < arr.length; i++) { - str += String.fromCharCode(arr[i]); - } - return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); - } - - // --- Output + validation --- - - function updateOutput(els) { - const { modal, codeEl, warningsEl } = els; - if (!codeEl) return; - - const values = collectValues(els); - const activeTab = modal.querySelector(".cg-output-tab.active"); - const format = activeTab ? activeTab.getAttribute("data-format") : "server-yml"; - - const hasValues = Object.keys(values).length > 0; - if (!hasValues) { - codeEl.innerHTML = 'Configure options on the left to generate your config...'; - setHidden(warningsEl, true); - return; - } - - let output; - if (format === "docker-compose") { - output = generateDockerCompose(values); - } else if (format === "env-vars") { - output = generateEnvVars(values); - } else { - output = generateServerYml(values); - } - - codeEl.textContent = output; - - // Validation warnings - const warnings = validate(values); - if (warningsEl) { - if (warnings.length) { - warningsEl.innerHTML = warnings.map((w) => `
${w}
`).join(""); - } - setHidden(warningsEl, !warnings.length); - } - } - - function validate(values) { - const warnings = []; - const baseUrl = values["base-url"] || ""; - - // base-url format - if (baseUrl) { - if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) { - warnings.push("base-url must start with http:// or https://"); - } else { - try { - const u = new URL(baseUrl); - if (u.pathname !== "/" && u.pathname !== "") { - warnings.push("base-url must not have a path, ntfy does not support sub-paths"); - } - } catch (e) { - warnings.push("base-url is not a valid URL"); - } - } - } - - // database-url must start with postgres:// - if (values["database-url"] && !values["database-url"].startsWith("postgres://")) { - warnings.push("database-url must start with postgres://"); - } - - // Web push requires all fields + base-url - const wpPublic = values["web-push-public-key"]; - const wpPrivate = values["web-push-private-key"]; - const wpEmail = values["web-push-email-address"]; - const wpFile = values["web-push-file"]; - const dbUrl = values["database-url"]; - if (wpPublic || wpPrivate || wpEmail) { - const missing = []; - if (!wpPublic) missing.push("web-push-public-key"); - if (!wpPrivate) missing.push("web-push-private-key"); - if (!wpFile && !dbUrl) missing.push("web-push-file or database-url"); - if (!wpEmail) missing.push("web-push-email-address"); - if (!baseUrl) missing.push("base-url"); - if (missing.length) { - warnings.push(`Web push requires: ${missing.join(", ")}`); - } - } - - // SMTP sender requires base-url and smtp-sender-from - if (values["smtp-sender-addr"]) { - const smtpMissing = []; - if (!baseUrl) smtpMissing.push("base-url"); - if (!values["smtp-sender-from"]) smtpMissing.push("smtp-sender-from"); - if (smtpMissing.length) { - warnings.push(`Email sending requires: ${smtpMissing.join(", ")}`); - } - } - - // SMTP server requires domain - if (values["smtp-server-listen"] && !values["smtp-server-domain"]) { - warnings.push("Email publishing requires smtp-server-domain"); - } - - // Attachments require base-url - if (values["attachment-cache-dir"] && !baseUrl) { - warnings.push("Attachments require base-url to be set"); - } - - // Upstream requires base-url and can't equal it - if (values["upstream-base-url"]) { - if (!baseUrl) { - warnings.push("Upstream server requires base-url to be set"); - } else if (baseUrl === values["upstream-base-url"]) { - warnings.push("base-url and upstream-base-url cannot be the same"); - } - } - - // enable-signup requires enable-login - if (values["enable-signup"] && !values["enable-login"]) { - warnings.push("Enable signup requires enable-login to also be set"); - } - - // Duration field validation - [ - { key: "cache-duration", label: "Cache duration" }, - { key: "attachment-expiry-duration", label: "Attachment expiry duration" }, - ].forEach((f) => { - if (values[f.key] && !durationRegex.test(values[f.key])) { - warnings.push(`${f.label} must be a valid duration (e.g. 12h, 3d, 30m, 60s)`); - } - }); - - // Size field validation - [ - { key: "attachment-file-size-limit", label: "Attachment file size limit" }, - { key: "attachment-total-size-limit", label: "Attachment total size limit" }, - ].forEach((f) => { - if (values[f.key] && !sizeRegex.test(values[f.key])) { - warnings.push(`${f.label} must be a valid size (e.g. 15M, 5G, 100K)`); - } - }); - - return warnings; - } - - // --- Helpers --- - - function generateToken() { - const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - let token = "tk_"; - for (let i = 0; i < 29; i++) { - token += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return token; - } - - function generatePassword() { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let password = ""; - for (let i = 0; i < 16; i++) { - password += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return password; - } - - function prefill(modal, key, value) { - const el = modal.querySelector(`[data-key="${key}"]`); - if (el && !el.value.trim() && !el.dataset.cleared) el.value = value; - } - - function switchPanel(modal, panelId) { - modal.querySelectorAll(".cg-nav-tab").forEach((t) => t.classList.remove("active")); - modal.querySelectorAll(".cg-panel").forEach((p) => p.classList.remove("active")); - - const navTab = modal.querySelector(`[data-panel="${panelId}"]`); - const panel = modal.querySelector(`#${panelId}`); - if (navTab) navTab.classList.add("active"); - if (panel) panel.classList.add("active"); - } - - function setHidden(el, hidden) { - if (!el) return; - if (hidden) { - el.classList.add("cg-hidden"); - } else { - el.classList.remove("cg-hidden"); - } - } - - // --- Visibility: broken into focused helpers --- - - function syncRadiosToHiddenInputs(els) { - const { modal, accessSelect, accessHidden, loginHidden, requireLoginHidden, signupHidden, proxyCheckbox } = els; - - // Proxy radio → hidden checkbox - const proxyYes = modal.querySelector('input[name="cg-proxy"][value="yes"]'); - if (proxyYes && proxyCheckbox) { - proxyCheckbox.checked = proxyYes.checked; - } - - // Default access select → hidden input - if (accessSelect && accessHidden) { - accessHidden.value = accessSelect.value; - } - - // Login mode three-way toggle → hidden checkboxes - const loginMode = modal.querySelector('input[name="cg-login-mode"]:checked'); - const loginModeVal = loginMode ? loginMode.value : "disabled"; - if (loginHidden) loginHidden.checked = (loginModeVal === "enabled" || loginModeVal === "required"); - if (requireLoginHidden) requireLoginHidden.checked = (loginModeVal === "required"); - - const signupYes = modal.querySelector('input[name="cg-enable-signup"][value="yes"]'); - if (signupYes && signupHidden) signupHidden.checked = signupYes.checked; - - return loginModeVal; - } - - function updateFeatureVisibility(els, flags) { - const { modal, dbStep, navDb, navEmail, emailOutSection, emailInSection } = els; - const { authEnabled, cacheEnabled, webpushEnabled, smtpOutEnabled, smtpInEnabled, needsDb, isPostgres } = flags; - - // Show database question only if a DB-dependent feature is selected - setHidden(dbStep, !needsDb); - - // Nav tabs for features - for (const featId in NAV_MAP) { - const checkbox = modal.querySelector(`#${featId}`); - const navTab = modal.querySelector(`#${NAV_MAP[featId]}`); - if (checkbox && navTab) { - setHidden(navTab, !checkbox.checked); - } - } - - // Email tab — show if either outgoing or incoming is enabled - setHidden(navEmail, !smtpOutEnabled && !smtpInEnabled); - setHidden(emailOutSection, !smtpOutEnabled); - setHidden(emailInSection, !smtpInEnabled); - - // Show/hide configure buttons next to feature checkboxes - modal.querySelectorAll(".cg-btn-configure").forEach((btn) => { - const row = btn.closest(".cg-feature-row"); - if (!row) return; - const cb = row.querySelector('input[type="checkbox"]'); - setHidden(btn, !(cb && cb.checked)); - }); - - // If active nav tab got hidden, switch to General - const activeNav = modal.querySelector(".cg-nav-tab.active"); - if (activeNav && activeNav.classList.contains("cg-hidden")) { - switchPanel(modal, "cg-panel-general"); - } - - // Database tab — show only when PostgreSQL is selected and a DB-dependent feature is on - setHidden(navDb, !(needsDb && isPostgres)); - } - - function updatePostgresFields(modal, isPostgres) { - // Show "Using PostgreSQL" instead of file inputs when PostgreSQL is selected - ["auth-file", "web-push-file", "cache-file"].forEach((key) => { - const input = modal.querySelector(`[data-key="${key}"]`); - if (!input) return; - const field = input.closest(".cg-field"); - if (!field) return; - input.style.display = isPostgres ? "none" : ""; - if (isPostgres) { - input.dataset.pgDisabled = "1"; - } else { - delete input.dataset.pgDisabled; - } - let pgLabel = field.querySelector(".cg-pg-label"); - if (isPostgres) { - if (!pgLabel) { - pgLabel = document.createElement("span"); - pgLabel.className = "cg-pg-label"; - pgLabel.textContent = "Using PostgreSQL"; - input.parentNode.insertBefore(pgLabel, input.nextSibling); - } - pgLabel.style.display = ""; - } else if (pgLabel) { - pgLabel.style.display = "none"; - } - }); - - // iOS question → upstream-base-url - const iosYes = modal.querySelector('input[name="cg-ios"][value="yes"]'); - const upstreamInput = modal.querySelector('[data-key="upstream-base-url"]'); - if (iosYes && upstreamInput) { - upstreamInput.value = iosYes.checked ? "https://ntfy.sh" : ""; - } - } - - function prefillDefaults(modal, flags) { - const { isPostgres, authEnabled, cacheEnabled, attachEnabled, webpushEnabled, smtpOutEnabled, smtpInEnabled } = flags; - - if (isPostgres) { - prefill(modal, "database-url", "postgres://user:pass@host:5432/ntfy"); - } - - if (authEnabled) { - if (!isPostgres) prefill(modal, "auth-file", "/var/lib/ntfy/auth.db"); - } - - if (cacheEnabled) { - if (!isPostgres) prefill(modal, "cache-file", "/var/cache/ntfy/cache.db"); - } - - if (attachEnabled) { - prefill(modal, "attachment-cache-dir", "/var/cache/ntfy/attachments"); - } - - if (webpushEnabled) { - if (!isPostgres) prefill(modal, "web-push-file", "/var/lib/ntfy/webpush.db"); - prefill(modal, "web-push-email-address", "admin@example.com"); - } - - if (smtpOutEnabled) { - prefill(modal, "smtp-sender-addr", "smtp.example.com:587"); - prefill(modal, "smtp-sender-from", "ntfy@example.com"); - prefill(modal, "smtp-sender-user", "yoursmtpuser"); - prefill(modal, "smtp-sender-pass", "yoursmtppass"); - } - - if (smtpInEnabled) { - prefill(modal, "smtp-server-listen", ":25"); - prefill(modal, "smtp-server-domain", "ntfy.example.com"); - } - } - - function autoDetectServerType(els, loginModeVal) { - const { modal, accessSelect } = els; - const serverTypeRadio = modal.querySelector('input[name="cg-server-type"]:checked'); - const serverType = serverTypeRadio ? serverTypeRadio.value : "open"; - - if (serverType !== "custom") { - const currentAccess = accessSelect ? accessSelect.value : "read-write"; - const currentLoginEnabled = loginModeVal !== "disabled"; - const matchesOpen = currentAccess === "read-write" && !currentLoginEnabled; - const matchesPrivate = currentAccess === "deny-all" && currentLoginEnabled; - if (!matchesOpen && !matchesPrivate) { - const customRadio = modal.querySelector('input[name="cg-server-type"][value="custom"]'); - if (customRadio) customRadio.checked = true; - } - } - } - - function updateVisibility(els) { - const { modal, authCheckbox, cacheCheckbox, attachCheckbox, webpushCheckbox, smtpOutCheckbox, smtpInCheckbox } = els; - - const isPostgresRadio = modal.querySelector('input[name="cg-db-type"][value="postgres"]'); - const isPostgres = isPostgresRadio && isPostgresRadio.checked; - - // Auto-enable auth when PostgreSQL is selected - if (isPostgres && authCheckbox && !authCheckbox.checked) { - authCheckbox.checked = true; - } - - const authEnabled = authCheckbox && authCheckbox.checked; - const cacheEnabled = cacheCheckbox && cacheCheckbox.checked; - const attachEnabled = attachCheckbox && attachCheckbox.checked; - const webpushEnabled = webpushCheckbox && webpushCheckbox.checked; - const smtpOutEnabled = smtpOutCheckbox && smtpOutCheckbox.checked; - const smtpInEnabled = smtpInCheckbox && smtpInCheckbox.checked; - const needsDb = authEnabled || cacheEnabled || webpushEnabled; - - const flags = { isPostgres, authEnabled, cacheEnabled, attachEnabled, webpushEnabled, smtpOutEnabled, smtpInEnabled, needsDb }; - - const loginModeVal = syncRadiosToHiddenInputs(els); - updateFeatureVisibility(els, flags); - updatePostgresFields(modal, isPostgres); - prefillDefaults(modal, flags); - autoDetectServerType(els, loginModeVal); - } - - // --- Repeatable rows --- - - function addRepeatableRow(container, type, onUpdate) { - const row = document.createElement("div"); - row.className = `cg-repeatable-row cg-auth-${type}-row`; - - if (type === "user") { - row.innerHTML = - '' + - '' + - '' + - ''; - } else if (type === "acl") { - row.innerHTML = - '' + - '' + - '' + - ''; - } else if (type === "token") { - row.innerHTML = - '' + - `` + - '' + - ''; - } - - row.querySelector(".cg-btn-remove").addEventListener("click", () => { - row.remove(); - onUpdate(); - }); - row.querySelectorAll("input, select").forEach((el) => { - el.addEventListener("input", onUpdate); - }); - - container.appendChild(row); - } - - // --- Modal functions (module-level) --- - - function openModal(els) { - els.modal.style.display = ""; - document.body.style.overflow = "hidden"; - updateVisibility(els); - updateOutput(els); - } - - function closeModal(els) { - els.modal.style.display = "none"; - document.body.style.overflow = ""; - } - - function resetAll(els) { - const { modal } = els; - - // Reset all text/password inputs and clear flags - modal.querySelectorAll('input[type="text"], input[type="password"]').forEach((el) => { - el.value = ""; - delete el.dataset.cleared; - }); - // Uncheck all checkboxes - modal.querySelectorAll('input[type="checkbox"]').forEach((el) => { - el.checked = false; - el.disabled = false; - }); - // Reset radio buttons to first option - const radioGroups = {}; - modal.querySelectorAll('input[type="radio"]').forEach((el) => { - if (!radioGroups[el.name]) { - radioGroups[el.name] = true; - const first = modal.querySelector(`input[type="radio"][name="${el.name}"]`); - if (first) first.checked = true; - } else { - el.checked = false; - } - }); - // Reset selects to first option - modal.querySelectorAll("select").forEach((el) => { - el.selectedIndex = 0; - }); - // Remove all repeatable rows - modal.querySelectorAll(".cg-auth-user-row, .cg-auth-acl-row, .cg-auth-token-row").forEach((row) => { - row.remove(); - }); - // Re-prefill base-url - const baseUrlInput = modal.querySelector('[data-key="base-url"]'); - if (baseUrlInput) { - const host = window.location.hostname; - if (host && !host.includes("ntfy.sh")) { - baseUrlInput.value = "https://ntfy.example.com"; - } - } - // Reset to General tab - switchPanel(modal, "cg-panel-general"); - updateVisibility(els); - updateOutput(els); - } - - function fillVAPIDKeys(els) { - const { modal } = els; - generateVAPIDKeys().then((keys) => { - const pubInput = modal.querySelector('[data-key="web-push-public-key"]'); - const privInput = modal.querySelector('[data-key="web-push-private-key"]'); - if (pubInput) pubInput.value = keys.publicKey; - if (privInput) privInput.value = keys.privateKey; - updateOutput(els); - }); - } - - // --- Event setup (grouped) --- - - function setupModalEvents(els) { - const { modal } = els; - const openBtn = document.getElementById("cg-open-btn"); - const closeBtn = document.getElementById("cg-close-btn"); - const backdrop = modal.querySelector(".cg-modal-backdrop"); - const resetBtn = document.getElementById("cg-reset-btn"); - - if (openBtn) openBtn.addEventListener("click", () => openModal(els)); - if (closeBtn) closeBtn.addEventListener("click", () => closeModal(els)); - if (resetBtn) resetBtn.addEventListener("click", () => resetAll(els)); - if (backdrop) backdrop.addEventListener("click", () => closeModal(els)); - - document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && modal.style.display !== "none") { - closeModal(els); - } - }); - } - - function setupAuthEvents(els) { - const { modal, authCheckbox, accessSelect } = els; - if (!authCheckbox) return; - - // Auth checkbox: clean up when unchecked - authCheckbox.addEventListener("change", () => { - if (!authCheckbox.checked) { - // Clear auth-file - const authFile = modal.querySelector('[data-key="auth-file"]'); - if (authFile) { authFile.value = ""; delete authFile.dataset.cleared; } - // Reset default access - if (accessSelect) accessSelect.value = "read-write"; - // Reset login mode to Disabled and unset hidden checkboxes - const loginDisabled = modal.querySelector('input[name="cg-login-mode"][value="disabled"]'); - if (loginDisabled) loginDisabled.checked = true; - if (els.loginHidden) els.loginHidden.checked = false; - if (els.requireLoginHidden) els.requireLoginHidden.checked = false; - const signupNo = modal.querySelector('input[name="cg-enable-signup"][value="no"]'); - if (signupNo) signupNo.checked = true; - if (els.signupHidden) els.signupHidden.checked = false; - // Reset UnifiedPush to No - const upNo = modal.querySelector('input[name="cg-unifiedpush"][value="no"]'); - if (upNo) upNo.checked = true; - // Remove provisioned users/ACLs/tokens - modal.querySelectorAll(".cg-auth-user-row, .cg-auth-acl-row, .cg-auth-token-row").forEach((row) => { - row.remove(); - }); - // Switch server type to Open - const openRadio = modal.querySelector('input[name="cg-server-type"][value="open"]'); - if (openRadio) openRadio.checked = true; - } - }); - } - - function setupServerTypeEvents(els) { - const { modal, authCheckbox, accessSelect } = els; - - modal.querySelectorAll('input[name="cg-server-type"]').forEach((radio) => { - radio.addEventListener("change", () => { - const loginDisabledRadio = modal.querySelector('input[name="cg-login-mode"][value="disabled"]'); - const loginRequiredRadio = modal.querySelector('input[name="cg-login-mode"][value="required"]'); - if (radio.value === "open") { - if (accessSelect) accessSelect.value = "read-write"; - if (loginDisabledRadio) loginDisabledRadio.checked = true; - if (authCheckbox) authCheckbox.checked = false; - // Trigger the auth cleanup - authCheckbox.dispatchEvent(new Event("change")); - } else if (radio.value === "private") { - // Enable auth with required login - if (authCheckbox) authCheckbox.checked = true; - if (accessSelect) accessSelect.value = "deny-all"; - if (loginRequiredRadio) loginRequiredRadio.checked = true; - if (els.loginHidden) els.loginHidden.checked = true; - if (els.requireLoginHidden) els.requireLoginHidden.checked = true; - // Add default admin user if no users exist - const usersContainer = modal.querySelector("#cg-auth-users-container"); - if (usersContainer && !usersContainer.querySelector(".cg-auth-user-row")) { - const onUpdate = () => { updateVisibility(els); updateOutput(els); }; - addRepeatableRow(usersContainer, "user", onUpdate); - const row = usersContainer.querySelector(".cg-auth-user-row:last-child"); - if (row) { - const u = row.querySelector('[data-field="username"]'); - const p = row.querySelector('[data-field="password"]'); - const r = row.querySelector('[data-field="role"]'); - if (u) u.value = "ntfyadmin"; - if (p) p.value = generatePassword(); - if (r) r.value = "admin"; - } - } - } - // "custom" doesn't change anything - }); - }); - } - - function setupFormListeners(els) { - const { modal } = els; - const onUpdate = () => { - updateVisibility(els); - updateOutput(els); - }; - - // Left nav tab switching - modal.querySelectorAll(".cg-nav-tab").forEach((tab) => { - tab.addEventListener("click", () => { - const panelId = tab.getAttribute("data-panel"); - switchPanel(modal, panelId); - }); - }); - - // Configure buttons in feature grid - modal.querySelectorAll(".cg-btn-configure").forEach((btn) => { - btn.addEventListener("click", () => { - const panelId = btn.getAttribute("data-panel"); - if (panelId) switchPanel(modal, panelId); - }); - }); - - // Output format tab switching - modal.querySelectorAll(".cg-output-tab").forEach((tab) => { - tab.addEventListener("click", () => { - modal.querySelectorAll(".cg-output-tab").forEach((t) => t.classList.remove("active")); - tab.classList.add("active"); - updateOutput(els); - }); - }); - - // All form inputs trigger update - modal.querySelectorAll("input, select").forEach((el) => { - const evt = (el.type === "checkbox" || el.type === "radio") ? "change" : "input"; - el.addEventListener(evt, () => { - // Mark text fields as cleared when user empties them - if ((el.type === "text" || el.type === "password") && el.dataset.key && !el.value.trim()) { - el.dataset.cleared = "1"; - } else if ((el.type === "text" || el.type === "password") && el.dataset.key && el.value.trim()) { - delete el.dataset.cleared; - } - onUpdate(); - }); - }); - - // Add buttons for repeatable rows - modal.querySelectorAll(".cg-btn-add[data-add-type]").forEach((btn) => { - btn.addEventListener("click", () => { - const type = btn.getAttribute("data-add-type"); - let container = btn.previousElementSibling; - if (!container) container = btn.parentElement.querySelector(".cg-repeatable-container"); - addRepeatableRow(container, type, onUpdate); - }); - }); - - // Copy button - const copyBtn = modal.querySelector("#cg-copy-btn"); - if (copyBtn) { - const copyIcon = ''; - const checkIcon = ''; - copyBtn.addEventListener("click", () => { - const code = modal.querySelector("#cg-code"); - if (code && code.textContent) { - navigator.clipboard.writeText(code.textContent).then(() => { - copyBtn.innerHTML = checkIcon; - copyBtn.style.color = "var(--md-primary-fg-color)"; - setTimeout(() => { - copyBtn.innerHTML = copyIcon; - copyBtn.style.color = ""; - }, 2000); - }); - } - }); - } - } - - function setupWebPushEvents(els) { - const { modal } = els; - let vapidKeysGenerated = false; - const regenBtn = modal.querySelector("#cg-regen-keys"); - - if (regenBtn) { - regenBtn.addEventListener("click", () => fillVAPIDKeys(els)); - } - - // Auto-generate keys when web push is first enabled - const webpushFeat = modal.querySelector("#cg-feat-webpush"); - if (webpushFeat) { - webpushFeat.addEventListener("change", () => { - if (webpushFeat.checked && !vapidKeysGenerated) { - vapidKeysGenerated = true; - fillVAPIDKeys(els); - } - }); - } - } - - // --- Init --- - - function initGenerator() { - const modal = document.getElementById("cg-modal"); - if (!modal) return; - - const els = cacheElements(modal); - - setupModalEvents(els); - setupAuthEvents(els); - setupServerTypeEvents(els); - setupFormListeners(els); - setupWebPushEvents(els); - - // Pre-fill base-url if not on ntfy.sh - const baseUrlInput = modal.querySelector('[data-key="base-url"]'); - if (baseUrlInput && !baseUrlInput.value.trim()) { - const host = window.location.hostname; - if (host && !host.includes("ntfy.sh")) { - baseUrlInput.value = "https://ntfy.example.com"; - } - } - - // Auto-open if URL hash points to config generator - if (window.location.hash === "#config-generator") { - openModal(els); - } - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initGenerator); + let output; + if (format === "docker-compose") { + output = generateDockerCompose(values); + } else if (format === "env-vars") { + output = generateEnvVars(values); } else { - initGenerator(); + output = generateServerYml(values); } + + codeEl.textContent = output; + + // Validation warnings + const warnings = validate(values); + if (warningsEl) { + if (warnings.length) { + warningsEl.innerHTML = warnings.map((w) => `
${w}
`).join(""); + } + setHidden(warningsEl, !warnings.length); + } + } + + function validate(values) { + const warnings = []; + const baseUrl = values["base-url"] || ""; + + // base-url format + if (baseUrl) { + if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) { + warnings.push("base-url must start with http:// or https://"); + } else { + try { + const u = new URL(baseUrl); + if (u.pathname !== "/" && u.pathname !== "") { + warnings.push("base-url must not have a path, ntfy does not support sub-paths"); + } + } catch (e) { + warnings.push("base-url is not a valid URL"); + } + } + } + + // database-url must start with postgres:// + if (values["database-url"] && !values["database-url"].startsWith("postgres://")) { + warnings.push("database-url must start with postgres://"); + } + + // Web push requires all fields + base-url + const wpPublic = values["web-push-public-key"]; + const wpPrivate = values["web-push-private-key"]; + const wpEmail = values["web-push-email-address"]; + const wpFile = values["web-push-file"]; + const dbUrl = values["database-url"]; + if (wpPublic || wpPrivate || wpEmail) { + const missing = []; + if (!wpPublic) missing.push("web-push-public-key"); + if (!wpPrivate) missing.push("web-push-private-key"); + if (!wpFile && !dbUrl) missing.push("web-push-file or database-url"); + if (!wpEmail) missing.push("web-push-email-address"); + if (!baseUrl) missing.push("base-url"); + if (missing.length) { + warnings.push(`Web push requires: ${missing.join(", ")}`); + } + } + + // SMTP sender requires base-url and smtp-sender-from + if (values["smtp-sender-addr"]) { + const smtpMissing = []; + if (!baseUrl) smtpMissing.push("base-url"); + if (!values["smtp-sender-from"]) smtpMissing.push("smtp-sender-from"); + if (smtpMissing.length) { + warnings.push(`Email sending requires: ${smtpMissing.join(", ")}`); + } + } + + // SMTP server requires domain + if (values["smtp-server-listen"] && !values["smtp-server-domain"]) { + warnings.push("Email publishing requires smtp-server-domain"); + } + + // Attachments require base-url + if (values["attachment-cache-dir"] && !baseUrl) { + warnings.push("Attachments require base-url to be set"); + } + + // Upstream requires base-url and can't equal it + if (values["upstream-base-url"]) { + if (!baseUrl) { + warnings.push("Upstream server requires base-url to be set"); + } else if (baseUrl === values["upstream-base-url"]) { + warnings.push("base-url and upstream-base-url cannot be the same"); + } + } + + // enable-signup requires enable-login + if (values["enable-signup"] && !values["enable-login"]) { + warnings.push("Enable signup requires enable-login to also be set"); + } + + // Duration field validation + [ + { key: "cache-duration", label: "Cache duration" }, + { key: "attachment-expiry-duration", label: "Attachment expiry duration" } + ].forEach((f) => { + if (values[f.key] && !durationRegex.test(values[f.key])) { + warnings.push(`${f.label} must be a valid duration (e.g. 12h, 3d, 30m, 60s)`); + } + }); + + // Size field validation + [ + { key: "attachment-file-size-limit", label: "Attachment file size limit" }, + { key: "attachment-total-size-limit", label: "Attachment total size limit" } + ].forEach((f) => { + if (values[f.key] && !sizeRegex.test(values[f.key])) { + warnings.push(`${f.label} must be a valid size (e.g. 15M, 5G, 100K)`); + } + }); + + return warnings; + } + + // --- Helpers --- + + function generateToken() { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let token = "tk_"; + for (let i = 0; i < 29; i++) { + token += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return token; + } + + function generatePassword() { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let password = ""; + for (let i = 0; i < 16; i++) { + password += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return password; + } + + function prefill(modal, key, value) { + const el = modal.querySelector(`[data-key="${key}"]`); + if (el && !el.value.trim() && !el.dataset.cleared) el.value = value; + } + + function switchPanel(modal, panelId) { + modal.querySelectorAll(".cg-nav-tab").forEach((t) => t.classList.remove("active")); + modal.querySelectorAll(".cg-panel").forEach((p) => p.classList.remove("active")); + + const navTab = modal.querySelector(`[data-panel="${panelId}"]`); + const panel = modal.querySelector(`#${panelId}`); + if (navTab) navTab.classList.add("active"); + if (panel) panel.classList.add("active"); + } + + function setHidden(el, hidden) { + if (!el) return; + if (hidden) { + el.classList.add("cg-hidden"); + } else { + el.classList.remove("cg-hidden"); + } + } + + // --- Visibility: broken into focused helpers --- + + function syncRadiosToHiddenInputs(els) { + const { modal, accessSelect, accessHidden, loginHidden, requireLoginHidden, signupHidden, proxyCheckbox } = els; + + // Proxy radio → hidden checkbox + const proxyYes = modal.querySelector("input[name=\"cg-proxy\"][value=\"yes\"]"); + if (proxyYes && proxyCheckbox) { + proxyCheckbox.checked = proxyYes.checked; + } + + // Default access select → hidden input + if (accessSelect && accessHidden) { + accessHidden.value = accessSelect.value; + } + + // Login mode three-way toggle → hidden checkboxes + const loginMode = modal.querySelector("input[name=\"cg-login-mode\"]:checked"); + const loginModeVal = loginMode ? loginMode.value : "disabled"; + if (loginHidden) loginHidden.checked = (loginModeVal === "enabled" || loginModeVal === "required"); + if (requireLoginHidden) requireLoginHidden.checked = (loginModeVal === "required"); + + const signupYes = modal.querySelector("input[name=\"cg-enable-signup\"][value=\"yes\"]"); + if (signupYes && signupHidden) signupHidden.checked = signupYes.checked; + + return loginModeVal; + } + + function updateFeatureVisibility(els, flags) { + const { modal, dbStep, navDb, navEmail, emailOutSection, emailInSection } = els; + const { authEnabled, cacheEnabled, webpushEnabled, smtpOutEnabled, smtpInEnabled, needsDb, isPostgres } = flags; + + // Show database question only if a DB-dependent feature is selected + setHidden(dbStep, !needsDb); + + // Nav tabs for features + for (const featId in NAV_MAP) { + const checkbox = modal.querySelector(`#${featId}`); + const navTab = modal.querySelector(`#${NAV_MAP[featId]}`); + if (checkbox && navTab) { + setHidden(navTab, !checkbox.checked); + } + } + + // Email tab — show if either outgoing or incoming is enabled + setHidden(navEmail, !smtpOutEnabled && !smtpInEnabled); + setHidden(emailOutSection, !smtpOutEnabled); + setHidden(emailInSection, !smtpInEnabled); + + // Show/hide configure buttons next to feature checkboxes + modal.querySelectorAll(".cg-btn-configure").forEach((btn) => { + const row = btn.closest(".cg-feature-row"); + if (!row) return; + const cb = row.querySelector("input[type=\"checkbox\"]"); + setHidden(btn, !(cb && cb.checked)); + }); + + // If active nav tab got hidden, switch to General + const activeNav = modal.querySelector(".cg-nav-tab.active"); + if (activeNav && activeNav.classList.contains("cg-hidden")) { + switchPanel(modal, "cg-panel-general"); + } + + // Database tab — show only when PostgreSQL is selected and a DB-dependent feature is on + setHidden(navDb, !(needsDb && isPostgres)); + } + + function updatePostgresFields(modal, isPostgres) { + // Show "Using PostgreSQL" instead of file inputs when PostgreSQL is selected + ["auth-file", "web-push-file", "cache-file"].forEach((key) => { + const input = modal.querySelector(`[data-key="${key}"]`); + if (!input) return; + const field = input.closest(".cg-field"); + if (!field) return; + input.style.display = isPostgres ? "none" : ""; + if (isPostgres) { + input.dataset.pgDisabled = "1"; + } else { + delete input.dataset.pgDisabled; + } + let pgLabel = field.querySelector(".cg-pg-label"); + if (isPostgres) { + if (!pgLabel) { + pgLabel = document.createElement("span"); + pgLabel.className = "cg-pg-label"; + pgLabel.textContent = "Using PostgreSQL"; + input.parentNode.insertBefore(pgLabel, input.nextSibling); + } + pgLabel.style.display = ""; + } else if (pgLabel) { + pgLabel.style.display = "none"; + } + }); + + // iOS question → upstream-base-url + const iosYes = modal.querySelector("input[name=\"cg-ios\"][value=\"yes\"]"); + const upstreamInput = modal.querySelector("[data-key=\"upstream-base-url\"]"); + if (iosYes && upstreamInput) { + upstreamInput.value = iosYes.checked ? "https://ntfy.sh" : ""; + } + } + + function prefillDefaults(modal, flags) { + const { + isPostgres, + authEnabled, + cacheEnabled, + attachEnabled, + webpushEnabled, + smtpOutEnabled, + smtpInEnabled + } = flags; + + if (isPostgres) { + prefill(modal, "database-url", "postgres://user:pass@host:5432/ntfy"); + } + + if (authEnabled) { + if (!isPostgres) prefill(modal, "auth-file", "/var/lib/ntfy/auth.db"); + } + + if (cacheEnabled) { + if (!isPostgres) prefill(modal, "cache-file", "/var/cache/ntfy/cache.db"); + } + + if (attachEnabled) { + prefill(modal, "attachment-cache-dir", "/var/cache/ntfy/attachments"); + } + + if (webpushEnabled) { + if (!isPostgres) prefill(modal, "web-push-file", "/var/lib/ntfy/webpush.db"); + prefill(modal, "web-push-email-address", "admin@example.com"); + } + + if (smtpOutEnabled) { + prefill(modal, "smtp-sender-addr", "smtp.example.com:587"); + prefill(modal, "smtp-sender-from", "ntfy@example.com"); + prefill(modal, "smtp-sender-user", "yoursmtpuser"); + prefill(modal, "smtp-sender-pass", "yoursmtppass"); + } + + if (smtpInEnabled) { + prefill(modal, "smtp-server-listen", ":25"); + prefill(modal, "smtp-server-domain", "ntfy.example.com"); + } + } + + function autoDetectServerType(els, loginModeVal) { + const { modal, accessSelect } = els; + const serverTypeRadio = modal.querySelector("input[name=\"cg-server-type\"]:checked"); + const serverType = serverTypeRadio ? serverTypeRadio.value : "open"; + + if (serverType !== "custom") { + const currentAccess = accessSelect ? accessSelect.value : "read-write"; + const currentLoginEnabled = loginModeVal !== "disabled"; + const matchesOpen = currentAccess === "read-write" && !currentLoginEnabled; + const matchesPrivate = currentAccess === "deny-all" && currentLoginEnabled; + if (!matchesOpen && !matchesPrivate) { + const customRadio = modal.querySelector("input[name=\"cg-server-type\"][value=\"custom\"]"); + if (customRadio) customRadio.checked = true; + } + } + } + + function updateVisibility(els) { + const { + modal, + authCheckbox, + cacheCheckbox, + attachCheckbox, + webpushCheckbox, + smtpOutCheckbox, + smtpInCheckbox + } = els; + + const isPostgresRadio = modal.querySelector("input[name=\"cg-db-type\"][value=\"postgres\"]"); + const isPostgres = isPostgresRadio && isPostgresRadio.checked; + + // Auto-enable auth when PostgreSQL is selected + if (isPostgres && authCheckbox && !authCheckbox.checked) { + authCheckbox.checked = true; + } + + const authEnabled = authCheckbox && authCheckbox.checked; + const cacheEnabled = cacheCheckbox && cacheCheckbox.checked; + const attachEnabled = attachCheckbox && attachCheckbox.checked; + const webpushEnabled = webpushCheckbox && webpushCheckbox.checked; + const smtpOutEnabled = smtpOutCheckbox && smtpOutCheckbox.checked; + const smtpInEnabled = smtpInCheckbox && smtpInCheckbox.checked; + const needsDb = authEnabled || cacheEnabled || webpushEnabled; + + const flags = { + isPostgres, + authEnabled, + cacheEnabled, + attachEnabled, + webpushEnabled, + smtpOutEnabled, + smtpInEnabled, + needsDb + }; + + const loginModeVal = syncRadiosToHiddenInputs(els); + updateFeatureVisibility(els, flags); + updatePostgresFields(modal, isPostgres); + prefillDefaults(modal, flags); + autoDetectServerType(els, loginModeVal); + } + + // --- Repeatable rows --- + + function addRepeatableRow(container, type, onUpdate) { + const row = document.createElement("div"); + row.className = `cg-repeatable-row cg-auth-${type}-row`; + + if (type === "user") { + const username = `newuser${Math.floor(Math.random() * 100) + 1}`; + row.innerHTML = + `` + + `` + + "" + + ""; + } else if (type === "acl") { + let aclUser = `someuser${Math.floor(Math.random() * 100) + 1}`; + const modal = container.closest(".cg-modal"); + if (modal) { + const userRows = modal.querySelectorAll(".cg-auth-user-row"); + for (const ur of userRows) { + const role = ur.querySelector("[data-field=\"role\"]"); + const name = ur.querySelector("[data-field=\"username\"]"); + if (role && role.value !== "admin" && name && name.value.trim()) { + aclUser = name.value.trim(); + break; + } + } + } + row.innerHTML = + `` + + "" + + "" + + ""; + } else if (type === "token") { + let tokenUser = ""; + const modal = container.closest(".cg-modal"); + if (modal) { + const firstRow = modal.querySelector(".cg-auth-user-row"); + const name = firstRow ? firstRow.querySelector("[data-field=\"username\"]") : null; + if (name && name.value.trim()) tokenUser = name.value.trim(); + } + row.innerHTML = + `` + + `` + + "" + + ""; + } + + row.querySelector(".cg-btn-remove").addEventListener("click", () => { + row.remove(); + onUpdate(); + }); + row.querySelectorAll("input, select").forEach((el) => { + el.addEventListener("input", onUpdate); + }); + + container.appendChild(row); + } + + // --- Modal functions (module-level) --- + + function openModal(els) { + els.modal.style.display = ""; + document.body.style.overflow = "hidden"; + updateVisibility(els); + updateOutput(els); + } + + function closeModal(els) { + els.modal.style.display = "none"; + document.body.style.overflow = ""; + } + + function resetAll(els) { + const { modal } = els; + + // Reset all text/password inputs and clear flags + modal.querySelectorAll("input[type=\"text\"], input[type=\"password\"]").forEach((el) => { + el.value = ""; + delete el.dataset.cleared; + }); + // Uncheck all checkboxes + modal.querySelectorAll("input[type=\"checkbox\"]").forEach((el) => { + el.checked = false; + el.disabled = false; + }); + // Reset radio buttons to first option + const radioGroups = {}; + modal.querySelectorAll("input[type=\"radio\"]").forEach((el) => { + if (!radioGroups[el.name]) { + radioGroups[el.name] = true; + const first = modal.querySelector(`input[type="radio"][name="${el.name}"]`); + if (first) first.checked = true; + } else { + el.checked = false; + } + }); + // Reset selects to first option + modal.querySelectorAll("select").forEach((el) => { + el.selectedIndex = 0; + }); + // Remove all repeatable rows + modal.querySelectorAll(".cg-auth-user-row, .cg-auth-acl-row, .cg-auth-token-row").forEach((row) => { + row.remove(); + }); + // Re-prefill base-url + const baseUrlInput = modal.querySelector("[data-key=\"base-url\"]"); + if (baseUrlInput) { + const host = window.location.hostname; + if (host && !host.includes("ntfy.sh")) { + baseUrlInput.value = "https://ntfy.example.com"; + } + } + // Reset to General tab + switchPanel(modal, "cg-panel-general"); + updateVisibility(els); + updateOutput(els); + } + + function fillVAPIDKeys(els) { + const { modal } = els; + generateVAPIDKeys().then((keys) => { + const pubInput = modal.querySelector("[data-key=\"web-push-public-key\"]"); + const privInput = modal.querySelector("[data-key=\"web-push-private-key\"]"); + if (pubInput) pubInput.value = keys.publicKey; + if (privInput) privInput.value = keys.privateKey; + updateOutput(els); + }); + } + + // --- Event setup (grouped) --- + + function setupModalEvents(els) { + const { modal } = els; + const openBtn = document.getElementById("cg-open-btn"); + const closeBtn = document.getElementById("cg-close-btn"); + const backdrop = modal.querySelector(".cg-modal-backdrop"); + const resetBtn = document.getElementById("cg-reset-btn"); + + if (openBtn) openBtn.addEventListener("click", () => openModal(els)); + if (closeBtn) closeBtn.addEventListener("click", () => closeModal(els)); + if (resetBtn) resetBtn.addEventListener("click", () => resetAll(els)); + if (backdrop) backdrop.addEventListener("click", () => closeModal(els)); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.style.display !== "none") { + closeModal(els); + } + }); + } + + function setupAuthEvents(els) { + const { modal, authCheckbox, accessSelect } = els; + if (!authCheckbox) return; + + // Auth checkbox: clean up when unchecked + authCheckbox.addEventListener("change", () => { + if (!authCheckbox.checked) { + // Clear auth-file + const authFile = modal.querySelector("[data-key=\"auth-file\"]"); + if (authFile) { + authFile.value = ""; + delete authFile.dataset.cleared; + } + // Reset default access + if (accessSelect) accessSelect.value = "read-write"; + // Reset login mode to Disabled and unset hidden checkboxes + const loginDisabled = modal.querySelector("input[name=\"cg-login-mode\"][value=\"disabled\"]"); + if (loginDisabled) loginDisabled.checked = true; + if (els.loginHidden) els.loginHidden.checked = false; + if (els.requireLoginHidden) els.requireLoginHidden.checked = false; + const signupNo = modal.querySelector("input[name=\"cg-enable-signup\"][value=\"no\"]"); + if (signupNo) signupNo.checked = true; + if (els.signupHidden) els.signupHidden.checked = false; + // Reset UnifiedPush to No + const upNo = modal.querySelector("input[name=\"cg-unifiedpush\"][value=\"no\"]"); + if (upNo) upNo.checked = true; + // Remove provisioned users/ACLs/tokens + modal.querySelectorAll(".cg-auth-user-row, .cg-auth-acl-row, .cg-auth-token-row").forEach((row) => { + row.remove(); + }); + // Switch server type to Open + const openRadio = modal.querySelector("input[name=\"cg-server-type\"][value=\"open\"]"); + if (openRadio) openRadio.checked = true; + } + }); + } + + function setupServerTypeEvents(els) { + const { modal, authCheckbox, accessSelect } = els; + + modal.querySelectorAll("input[name=\"cg-server-type\"]").forEach((radio) => { + radio.addEventListener("change", () => { + const loginDisabledRadio = modal.querySelector("input[name=\"cg-login-mode\"][value=\"disabled\"]"); + const loginRequiredRadio = modal.querySelector("input[name=\"cg-login-mode\"][value=\"required\"]"); + if (radio.value === "open") { + if (accessSelect) accessSelect.value = "read-write"; + if (loginDisabledRadio) loginDisabledRadio.checked = true; + if (authCheckbox) authCheckbox.checked = false; + // Trigger the auth cleanup + authCheckbox.dispatchEvent(new Event("change")); + } else if (radio.value === "private") { + // Enable auth with required login + if (authCheckbox) authCheckbox.checked = true; + if (accessSelect) accessSelect.value = "deny-all"; + if (loginRequiredRadio) loginRequiredRadio.checked = true; + if (els.loginHidden) els.loginHidden.checked = true; + if (els.requireLoginHidden) els.requireLoginHidden.checked = true; + // Add default admin user if no users exist + const usersContainer = modal.querySelector("#cg-auth-users-container"); + if (usersContainer && !usersContainer.querySelector(".cg-auth-user-row")) { + const onUpdate = () => { + updateVisibility(els); + updateOutput(els); + }; + addRepeatableRow(usersContainer, "user", onUpdate); + const adminRow = usersContainer.querySelector(".cg-auth-user-row:last-child"); + if (adminRow) { + const u = adminRow.querySelector("[data-field=\"username\"]"); + const p = adminRow.querySelector("[data-field=\"password\"]"); + const r = adminRow.querySelector("[data-field=\"role\"]"); + if (u) u.value = "ntfyadmin"; + if (p) p.value = generatePassword(); + if (r) r.value = "admin"; + } + addRepeatableRow(usersContainer, "user", onUpdate); + const userRow = usersContainer.querySelector(".cg-auth-user-row:last-child"); + if (userRow) { + const u = userRow.querySelector("[data-field=\"username\"]"); + const p = userRow.querySelector("[data-field=\"password\"]"); + if (u) u.value = "ntfyuser"; + if (p) p.value = generatePassword(); + } + } + } + // "custom" doesn't change anything + }); + }); + } + + function setupUnifiedPushEvents(els) { + const { modal } = els; + const onUpdate = () => { + updateVisibility(els); + updateOutput(els); + }; + + modal.querySelectorAll("input[name=\"cg-unifiedpush\"]").forEach((radio) => { + radio.addEventListener("change", () => { + const aclsContainer = modal.querySelector("#cg-auth-acls-container"); + if (!aclsContainer) return; + const existing = aclsContainer.querySelector(".cg-auth-acl-row-up"); + if (radio.value === "yes" && radio.checked && !existing) { + // Enable auth if not already enabled + if (els.authCheckbox && !els.authCheckbox.checked) { + els.authCheckbox.checked = true; + } + // Add a disabled UnifiedPush ACL row + const row = document.createElement("div"); + row.className = "cg-repeatable-row cg-auth-acl-row cg-auth-acl-row-up"; + row.innerHTML = + "" + + "" + + "" + + ""; + row.querySelector(".cg-btn-remove").addEventListener("click", () => { + row.remove(); + const upNo = modal.querySelector("input[name=\"cg-unifiedpush\"][value=\"no\"]"); + if (upNo) upNo.checked = true; + onUpdate(); + }); + // Insert at the beginning + aclsContainer.insertBefore(row, aclsContainer.firstChild); + onUpdate(); + } else if (radio.value === "no" && radio.checked && existing) { + existing.remove(); + onUpdate(); + } + }); + }); + } + + function setupFormListeners(els) { + const { modal } = els; + const onUpdate = () => { + updateVisibility(els); + updateOutput(els); + }; + + // Left nav tab switching + modal.querySelectorAll(".cg-nav-tab").forEach((tab) => { + tab.addEventListener("click", () => { + const panelId = tab.getAttribute("data-panel"); + switchPanel(modal, panelId); + }); + }); + + // Configure buttons in feature grid + modal.querySelectorAll(".cg-btn-configure").forEach((btn) => { + btn.addEventListener("click", () => { + const panelId = btn.getAttribute("data-panel"); + if (panelId) switchPanel(modal, panelId); + }); + }); + + // Output format tab switching + modal.querySelectorAll(".cg-output-tab").forEach((tab) => { + tab.addEventListener("click", () => { + modal.querySelectorAll(".cg-output-tab").forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); + updateOutput(els); + }); + }); + + // All form inputs trigger update + modal.querySelectorAll("input, select").forEach((el) => { + const evt = (el.type === "checkbox" || el.type === "radio") ? "change" : "input"; + el.addEventListener(evt, () => { + // Mark text fields as cleared when user empties them + if ((el.type === "text" || el.type === "password") && el.dataset.key && !el.value.trim()) { + el.dataset.cleared = "1"; + } else if ((el.type === "text" || el.type === "password") && el.dataset.key && el.value.trim()) { + delete el.dataset.cleared; + } + onUpdate(); + }); + }); + + // Add buttons for repeatable rows + modal.querySelectorAll(".cg-btn-add[data-add-type]").forEach((btn) => { + btn.addEventListener("click", () => { + const type = btn.getAttribute("data-add-type"); + let container = btn.previousElementSibling; + if (!container) container = btn.parentElement.querySelector(".cg-repeatable-container"); + addRepeatableRow(container, type, onUpdate); + onUpdate(); + }); + }); + + // Copy button + const copyBtn = modal.querySelector("#cg-copy-btn"); + if (copyBtn) { + const copyIcon = ""; + const checkIcon = ""; + copyBtn.addEventListener("click", () => { + const code = modal.querySelector("#cg-code"); + if (code && code.textContent) { + navigator.clipboard.writeText(code.textContent).then(() => { + copyBtn.innerHTML = checkIcon; + copyBtn.style.color = "var(--md-primary-fg-color)"; + setTimeout(() => { + copyBtn.innerHTML = copyIcon; + copyBtn.style.color = ""; + }, 2000); + }); + } + }); + } + } + + function setupWebPushEvents(els) { + const { modal } = els; + let vapidKeysGenerated = false; + const regenBtn = modal.querySelector("#cg-regen-keys"); + + if (regenBtn) { + regenBtn.addEventListener("click", () => fillVAPIDKeys(els)); + } + + // Auto-generate keys when web push is first enabled + const webpushFeat = modal.querySelector("#cg-feat-webpush"); + if (webpushFeat) { + webpushFeat.addEventListener("change", () => { + if (webpushFeat.checked && !vapidKeysGenerated) { + vapidKeysGenerated = true; + fillVAPIDKeys(els); + } + }); + } + } + + // --- Init --- + + function initGenerator() { + const modal = document.getElementById("cg-modal"); + if (!modal) return; + + const els = cacheElements(modal); + + setupModalEvents(els); + setupAuthEvents(els); + setupServerTypeEvents(els); + setupUnifiedPushEvents(els); + setupFormListeners(els); + setupWebPushEvents(els); + + // Pre-fill base-url if not on ntfy.sh + const baseUrlInput = modal.querySelector("[data-key=\"base-url\"]"); + if (baseUrlInput && !baseUrlInput.value.trim()) { + const host = window.location.hostname; + if (host && !host.includes("ntfy.sh")) { + baseUrlInput.value = "https://ntfy.example.com"; + } + } + + // Auto-open if URL hash points to config generator + if (window.location.hash === "#config-generator") { + openModal(els); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initGenerator); + } else { + initGenerator(); + } })(); diff --git a/go.mod b/go.mod index 6dd9384f..c073d6aa 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( 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/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/emersion/go-smtp v0.18.0 @@ -14,12 +14,12 @@ require ( github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 - golang.org/x/crypto v0.48.0 - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.19.0 - golang.org/x/term v0.40.0 - golang.org/x/time v0.14.0 - google.golang.org/api v0.269.0 + golang.org/x/crypto v0.49.0 + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 + golang.org/x/term v0.41.0 + golang.org/x/time v0.15.0 + google.golang.org/api v0.271.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -34,14 +34,14 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/sys v0.41.0 - golang.org/x/text v0.34.0 + golang.org/x/sys v0.42.0 + golang.org/x/text v0.35.0 ) require ( cel.dev/expr v0.25.1 // 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/compute/metadata v0.9.0 // 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/uuid v1.6.0 // 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/jackc/pgpassfile v1.0.0 // 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/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.41.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/sdk v1.41.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.51.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.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.67.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/net v0.52.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index c39fc3a1..1c6eada9 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 h1:PJt3KrySfZkKdcEV2wlyNkfAPbMZGjtnv5oLrT4tWPg= +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/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 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/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8= -cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0= +cloud.google.com/go/storage v1.61.1 h1:VELCSvZKiSw0AS1k3so5mKGy3CB7bTCYD8EHhTF42bY= +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/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= 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/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= +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/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 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= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.41.0 h1:MBzEwqhroF0JK0DpTVYWDxsenxm6L4PqOEfA90uZ5AA= -go.opentelemetry.io/contrib/detectors/gcp v1.41.0/go.mod h1:5pSDD0v0t2HqUmPC5cBBc+nLQO4dLYWnzBNheXLBLgs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 h1:w/o339tDd6Qtu3+ytwt+/jon2yjAs3Ot8Xq8pelfhSo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0/go.mod h1:pdhNtM9C4H5fRdrnwO7NjxzQWhKSSxCHk/KluVqDVC0= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= -go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= -go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= -go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +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.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +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.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +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-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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +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.8.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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 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.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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= @@ -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.7.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.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +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-20201119102817-f84b799fce68/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.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +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/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= @@ -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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 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.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +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.3/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.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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= @@ -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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= -google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= +google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= +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/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 h1:RxhCsti413yL0IjU9dVvuTbCISo8gs3RW1jPMStck+4= -google.golang.org/genproto v0.0.0-20260226221140-a57be14db171/go.mod h1:uhvzakVEqAuXU3TC2JCsxIRe5f77l+JySE3EqPoMyqM= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c h1:ZhFDeBMmFc/4g8/GwxnJ4rzB3O4GwQVNr+8Mh7Y5z4g= +google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c/go.mod h1:hf4r/rBuzaTkLUWRO03771Xvcs6P5hwdQK3UUEJjqo0= +google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI= +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-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/message/cache.go b/message/cache.go index 3b12af3e..b123fba4 100644 --- a/message/cache.go +++ b/message/cache.go @@ -50,14 +50,14 @@ type queries struct { // Cache stores published messages type Cache struct { - db *sql.DB + db *db.DB queue *util.BatchingQueue[*model.Message] nop bool mu *sync.Mutex // nil for PostgreSQL (concurrent writes supported), set for SQLite (single writer) 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] if batchSize > 0 || batchTimeout > 0 { 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) { var rows *sql.Rows var err error + rdb := c.db.ReadOnly() 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 { - 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 { 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) { var rows *sql.Rows var err error + rdb := c.db.ReadOnly() if scheduled { - rows, err = c.db.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID()) + rows, err = rdb.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID()) } 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 { 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) { - rows, err := c.db.Query(c.queries.selectMessagesLatest, topic) + rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesLatest, topic) if err != nil { 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 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 { 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 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 { 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 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 { 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 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 { 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 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 { return 0, err } @@ -466,7 +468,7 @@ func (c *Cache) UpdateStats(messages int64) error { // Stats returns the total message count statistic 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 { return 0, err } diff --git a/message/cache_postgres.go b/message/cache_postgres.go index 0146f409..ba162da2 100644 --- a/message/cache_postgres.go +++ b/message/cache_postgres.go @@ -1,8 +1,9 @@ package message import ( - "database/sql" "time" + + "heckel.io/ntfy/v2/db" ) // 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. -func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) { - if err := setupPostgres(db); err != nil { +func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) { + if err := setupPostgres(d.Primary()); err != nil { return nil, err } - return newCache(db, postgresQueries, nil, batchSize, batchTimeout, false), nil + return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil } diff --git a/message/cache_sqlite.go b/message/cache_sqlite.go index f9d8605e..a36aba0e 100644 --- a/message/cache_sqlite.go +++ b/message/cache_sqlite.go @@ -8,6 +8,7 @@ import ( "time" _ "github.com/mattn/go-sqlite3" // SQLite driver + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/util" ) @@ -110,14 +111,14 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration if !util.FileExists(parentDir) { return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir) } - db, err := sql.Open("sqlite3", filename) + d, err := sql.Open("sqlite3", filename) if err != nil { return nil, err } - if err := setupSQLite(db, startupQueries, cacheDuration); err != nil { + if err := setupSQLite(d, startupQueries, cacheDuration); err != nil { 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 diff --git a/mkdocs.yml b/mkdocs.yml index 022ba03e..76b5c1f9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ extra: link: https://github.com/binwiederhier extra_javascript: - static/js/extra.js + - static/js/bcrypt.js - static/js/config-generator.js extra_css: - static/css/extra.css diff --git a/server/config.go b/server/config.go index 786f0d78..8ead312c 100644 --- a/server/config.go +++ b/server/config.go @@ -95,7 +95,8 @@ type Config struct { ListenUnixMode fs.FileMode KeyFile 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 CacheFile string CacheDuration time.Duration diff --git a/server/server.go b/server/server.go index 329b0ab5..24c712bd 100644 --- a/server/server.go +++ b/server/server.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/sha256" - "database/sql" "embed" "encoding/base64" "encoding/json" @@ -33,6 +32,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/message" @@ -47,7 +47,7 @@ import ( // Server is the main server, providing the UI and API for ntfy type Server struct { config *Config - db *sql.DB // Shared PostgreSQL connection pool, nil when using SQLite + db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite httpServer *http.Server httpsServer *http.Server httpMetricsServer *http.Server @@ -179,13 +179,26 @@ func New(conf *Config) (*Server, error) { stripe = newStripeAPI() } // Open shared PostgreSQL connection pool if configured - var pool *sql.DB + var pool *db.DB if conf.DatabaseURL != "" { - var err error - pool, err = pg.Open(conf.DatabaseURL) + primary, err := pg.Open(conf.DatabaseURL) if err != nil { 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) if err != nil { @@ -277,7 +290,7 @@ func New(conf *Config) (*Server, error) { 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 { return message.NewNopStore() } else if pool != nil { diff --git a/test/server.go b/test/server.go index 5398cf9e..21e3af78 100644 --- a/test/server.go +++ b/test/server.go @@ -3,7 +3,7 @@ package test import ( "fmt" "heckel.io/ntfy/v2/server" - "math/rand" + "net" "net/http" "path/filepath" "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 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.AttachmentCacheDir = t.TempDir() 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 } +// 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 func StopServer(t *testing.T, s *server.Server, port int) { s.Stop() diff --git a/tools/pgimport/main.go b/tools/pgimport/main.go index cbc171dd..3ba5273e 100644 --- a/tools/pgimport/main.go +++ b/tools/pgimport/main.go @@ -236,10 +236,11 @@ func execImport(c *cli.Context) error { } fmt.Println() - pgDB, err := pg.Open(databaseURL) + pgHost, err := pg.Open(databaseURL) if err != nil { return fmt.Errorf("cannot connect to PostgreSQL: %w", err) } + pgDB := pgHost.DB defer pgDB.Close() if c.Bool("create-schema") { diff --git a/user/manager.go b/user/manager.go index 0ee6a6e1..bc1a13d3 100644 --- a/user/manager.go +++ b/user/manager.go @@ -49,7 +49,7 @@ var ( // Manager handles user authentication, authorization, and management type Manager struct { config *Config - db *sql.DB + db *db.DB queries queries statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) @@ -58,7 +58,7 @@ type Manager struct { 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 { config.BcryptCost = DefaultUserPasswordBcryptCost } @@ -67,7 +67,7 @@ func newManager(db *sql.DB, queries queries, config *Config) (*Manager, error) { } manager := &Manager{ config: config, - db: db, + db: d, statsQueue: make(map[string]*Stats), tokenQueue: make(map[string]*TokenUpdate), 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 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 { 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 // rather than one query per user to avoid N+1 performance issues. func (a *Manager) Users() ([]*User, error) { - rows, err := a.db.Query(a.queries.selectUsers) + rows, err := a.db.ReadOnly().Query(a.queries.selectUsers) if err != nil { return nil, err } @@ -434,7 +434,7 @@ func (a *Manager) Users() ([]*User, error) { // UsersCount returns the number of users in the database 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 { 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 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 { return nil, err } @@ -688,7 +688,7 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) { // Grants returns all user-specific access control entries func (a *Manager) Grants(username string) ([]Grant, error) { - rows, err := a.db.Query(a.queries.selectUserAccess, username) + rows, err := a.db.ReadOnly().Query(a.queries.selectUserAccess, username) if err != nil { 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 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 { 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 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 { return 0, err } @@ -962,7 +962,7 @@ func (a *Manager) canChangeToken(userID, token string) error { // Token returns a specific token for a user func (a *Manager) Token(userID, token string) (*Token, error) { - rows, err := a.db.Query(a.queries.selectToken, userID, token) + rows, err := a.db.ReadOnly().Query(a.queries.selectToken, userID, token) if err != nil { 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 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 { return nil, err } @@ -991,7 +991,7 @@ func (a *Manager) Tokens(userID string) ([]*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 { return nil, err } @@ -1114,7 +1114,7 @@ func (a *Manager) RemoveTier(code string) error { // Tiers returns a list of all Tier structs 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 { 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 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 { 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 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 { 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 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 { return nil, err } diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 7138ae2c..77c35ece 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -1,7 +1,7 @@ package user import ( - "database/sql" + "heckel.io/ntfy/v2/db" ) // PostgreSQL queries @@ -278,9 +278,9 @@ var postgresQueries = queries{ } // NewPostgresManager creates a new Manager backed by a PostgreSQL database -func NewPostgresManager(db *sql.DB, config *Config) (*Manager, error) { - if err := setupPostgres(db); err != nil { +func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) { + if err := setupPostgres(d.Primary()); err != nil { return nil, err } - return newManager(db, postgresQueries, config) + return newManager(d, postgresQueries, config) } diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index b4068599..e92c6349 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -7,6 +7,7 @@ import ( _ "github.com/mattn/go-sqlite3" // SQLite driver + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/util" ) @@ -280,15 +281,15 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager if !util.FileExists(parentDir) { return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir) } - db, err := sql.Open("sqlite3", filename) + d, err := sql.Open("sqlite3", filename) if err != nil { return nil, err } - if err := setupSQLite(db); err != nil { + if err := setupSQLite(d); err != nil { return nil, err } - if err := runSQLiteStartupQueries(db, startupQueries); err != nil { + if err := runSQLiteStartupQueries(d, startupQueries); err != nil { return nil, err } - return newManager(db, sqliteQueries, config) + return newManager(db.New(&db.Host{DB: d}, nil), sqliteQueries, config) } diff --git a/user/manager_test.go b/user/manager_test.go index 53cae1d1..3e023909 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" + "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" dbtest "heckel.io/ntfy/v2/db/test" "heckel.io/ntfy/v2/util" @@ -36,9 +37,9 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newManager newManagerFunc t.Run("postgres", func(t *testing.T) { schemaDSN := dbtest.CreateTestPostgresSchema(t) f(t, func(config *Config) *Manager { - pool, err := pg.Open(schemaDSN) + host, err := pg.Open(schemaDSN) require.Nil(t, err) - a, err := NewPostgresManager(pool, config) + a, err := NewPostgresManager(db.New(host, nil), config) require.Nil(t, err) return a }) @@ -1734,8 +1735,8 @@ func TestMigrationFrom4(t *testing.T) { require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters } -func checkSchemaVersion(t *testing.T, db *sql.DB) { - rows, err := db.Query(`SELECT version FROM schemaVersion`) +func checkSchemaVersion(t *testing.T, d *db.DB) { + rows, err := d.Query(`SELECT version FROM schemaVersion`) require.Nil(t, err) require.True(t, rows.Next()) @@ -1771,7 +1772,7 @@ func newTestManagerFromConfig(t *testing.T, newManager newManagerFunc, conf *Con return a } -func testDB(a *Manager) *sql.DB { +func testDB(a *Manager) *db.DB { return a.db } diff --git a/web/package-lock.json b/web/package-lock.json index e775138f..bec8660f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -4324,9 +4324,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.0.tgz", + "integrity": "sha512-04cg8iJFDOxWcYlu0GFFWgs7vtaEPCmr5w1nrj9V3z3axu/48HCMwK6VMp45Zh3ZB+xLP1ifbJfrq86+1ypKKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4345,6 +4345,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" }, "engines": { @@ -5065,9 +5066,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, diff --git a/webpush/store.go b/webpush/store.go index 9a93a074..02b7552e 100644 --- a/webpush/store.go +++ b/webpush/store.go @@ -24,7 +24,7 @@ var ( // Store holds the database connection and queries for web push subscriptions. type Store struct { - db *sql.DB + db *db.DB queries queries } @@ -83,7 +83,7 @@ func (s *Store) UpsertSubscription(endpoint string, auth, p256dh, userID string, // SubscriptionsForTopic returns all subscriptions for the given topic. 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 { 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. 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 { return nil, err } diff --git a/webpush/store_postgres.go b/webpush/store_postgres.go index ec541d37..1c9adf0a 100644 --- a/webpush/store_postgres.go +++ b/webpush/store_postgres.go @@ -73,12 +73,12 @@ const ( ) // NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool. -func NewPostgresStore(db *sql.DB) (*Store, error) { - if err := setupPostgres(db); err != nil { +func NewPostgresStore(d *db.DB) (*Store, error) { + if err := setupPostgres(d.Primary()); err != nil { return nil, err } return &Store{ - db: db, + db: d, queries: queries{ selectSubscriptionIDByEndpoint: postgresSelectSubscriptionIDByEndpointQuery, selectSubscriptionCountBySubscriberIP: postgresSelectSubscriptionCountBySubscriberIPQuery, @@ -97,11 +97,11 @@ func NewPostgresStore(db *sql.DB) (*Store, error) { }, nil } -func setupPostgres(db *sql.DB) error { +func setupPostgres(d *sql.DB) error { var schemaVersion int - err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) + err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { - return setupNewPostgres(db) + return setupNewPostgres(d) } if 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 } -func setupNewPostgres(sqlDB *sql.DB) error { - return db.ExecTx(sqlDB, func(tx *sql.Tx) error { +func setupNewPostgres(d *sql.DB) error { + return db.ExecTx(d, func(tx *sql.Tx) error { if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { return err } diff --git a/webpush/store_sqlite.go b/webpush/store_sqlite.go index 4ef78140..fcf49fcf 100644 --- a/webpush/store_sqlite.go +++ b/webpush/store_sqlite.go @@ -79,18 +79,18 @@ const ( // NewSQLiteStore creates a new SQLite-backed web push store. func NewSQLiteStore(filename, startupQueries string) (*Store, error) { - db, err := sql.Open("sqlite3", filename) + d, err := sql.Open("sqlite3", filename) if err != nil { return nil, err } - if err := setupSQLite(db); err != nil { + if err := setupSQLite(d); err != nil { return nil, err } - if err := runSQLiteStartupQueries(db, startupQueries); err != nil { + if err := runSQLiteStartupQueries(d, startupQueries); err != nil { return nil, err } return &Store{ - db: db, + db: db.New(&db.Host{DB: d}, nil), queries: queries{ selectSubscriptionIDByEndpoint: sqliteSelectSubscriptionIDByEndpointQuery, selectSubscriptionCountBySubscriberIP: sqliteSelectSubscriptionCountBySubscriberIPQuery,