Compare commits

...
Author SHA1 Message Date
dependabot[bot] 2731396e64 Bump react-i18next from 11.18.6 to 17.0.11 in /web
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 11.18.6 to 17.0.11.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v11.18.6...v17.0.11)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.11
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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


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

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

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

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/lv/
2026-07-28 14:02:00 +00:00
binwiederhier 4abdeb8d57 Comment 2026-07-28 07:34:27 +02:00
binwiederhier 7fb1d25740 Restructure a little 2026-07-27 23:48:43 +02:00
binwiederhier ef121a3f6c Schema migration 2026-07-27 17:51:32 +02:00
Vadym Nekhai 4a0f66e258 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (459 of 459 strings)

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

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

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

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

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-23 17:01:27 +02:00
binwiederhier f2d5c1ce6c Do not include secrets in the config hash 2026-07-23 08:13:27 +02:00
binwiederhier 7680cb4906 Ban-feed 2026-07-20 23:49:17 +02:00
binwiederhier 706fa3b491 Remove "experimental" from postgres option 2026-07-20 23:48:17 +02:00
binwiederhier 2bc145f3ae Merge branch 'release-2.26.x' 2026-07-20 23:42:03 +02:00
binwiederhier f8d2fcd7a6 Move metrics to metrics/ pacakge 2026-07-17 22:08:21 +02:00
binwiederhier ac63a2eea0 Move Twilio to twilio/ package 2026-07-17 13:48:48 +02:00
Simon Ramsay 07e1260b89 Add action and template directories to Dockerfile-build
Added new directories for action and template to the build process.
2026-07-16 20:47:51 -07:00
dashhrafa af4d85ec03 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/
2026-07-16 22:01:23 +02:00
binwiederhier 24bc50b585 Allow logging in via email 2026-07-16 21:42:42 +02:00
binwiederhier b55e78a918 Release notes 2026-07-12 10:23:38 +02:00
Philipp C. Heckel 6638699d48 Merge pull request #1830 from binwiederhier/template-exec-context
Template exec context, redone
2026-07-10 21:19:38 +02:00
binwiederhier 3f56dae54a Template exec context, redone 2026-07-10 13:18:11 +02:00
binwiederhier 1e4e3b6e36 Remove unreachable link 2026-07-09 23:07:07 +02:00
binwiederhier 75c687de1c Bump Android pre-release 2026-07-09 22:50:11 +02:00
Holden Oullette b15213e531 Add ex_ntfy (Elixir library) to integrations 2026-07-05 22:32:25 -06:00
78 changed files with 3842 additions and 2273 deletions
+3 -3
View File
@@ -8,13 +8,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
-
name: Checkout ntfy code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
name: Checkout docs pages code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: binwiederhier/ntfy-docs.github.io
path: build/ntfy-docs.github.io
+4 -4
View File
@@ -25,19 +25,19 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Docker login
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
+3 -3
View File
@@ -25,13 +25,13 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
+3
View File
@@ -48,6 +48,9 @@ ADD ./webpush ./webpush
ADD ./attachment ./attachment
ADD ./mail ./mail
ADD ./s3 ./s3
ADD ./action ./action
ADD ./template/gotext ./template/gotext
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
FROM alpine
+6
View File
@@ -13,6 +13,12 @@ import (
"heckel.io/ntfy/v2/db"
)
// Advisory lock keys. PostgreSQL advisory locks share one database-wide key space, so every
// ntfy key is defined here, following the "ntfy"+2586+letter scheme
const (
SchemaLockKey = int64(0x6e7466792586a) // Schema setup serialization (transaction-scoped, see db/schema)
)
// 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) {
+111
View File
@@ -0,0 +1,111 @@
// Package schema tracks and migrates database schemas, and Migrate creates or upgrades a
// store's schema inside a single transaction. On PostgreSQL, all stores share one database, so
// versions live in a shared schema_version table keyed by store name. On SQLite, every store is
// its own database file, so the version lives in the schemaVersion table keyed by id = 1.
package schema
import (
"database/sql"
"errors"
"fmt"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log"
)
const (
tag = "schema"
)
const (
sqliteCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`
sqliteSelectVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
sqliteUpsertVersionQuery = `INSERT INTO schemaVersion (id, version) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET version = excluded.version`
postgresCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schema_version (store TEXT PRIMARY KEY, version INT NOT NULL)`
postgresSelectVersionQuery = `SELECT version FROM schema_version WHERE store = $1`
postgresUpsertVersionQuery = `INSERT INTO schema_version (store, version) VALUES ($1, $2) ON CONFLICT (store) DO UPDATE SET version = EXCLUDED.version`
postgresAdvisoryLockQuery = `SELECT pg_advisory_xact_lock($1)` // Transaction-scoped lock to avoid migration races
)
// Migrate creates or upgrades the named store's schema to targetVersion in one transaction, or
// creates a new database using the "create" function.
func Migrate(db *sql.DB, dialect Dialect, store string, targetVersion int, create MigrateFunc, migrations map[int]MigrateFunc) error {
if dialect != Postgres && dialect != SQLite {
return fmt.Errorf("unsupported schema dialect %d", dialect)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("cannot begin %s schema transaction: %w", store, err)
}
defer tx.Rollback()
if dialect == Postgres {
// Serialize setup across nodes: CREATE TABLE IF NOT EXISTS is not atomic, and
// concurrently cold-booting nodes would otherwise race on DDL and crash
if _, err := tx.Exec(postgresAdvisoryLockQuery, pg.SchemaLockKey); err != nil {
return fmt.Errorf("cannot acquire %s schema advisory lock: %w", store, err)
}
}
if _, err := tx.Exec(createVersionTableQuery(dialect)); err != nil {
return fmt.Errorf("cannot create schema version table: %w", err)
}
version, err := readVersion(tx, dialect, store)
if errors.Is(err, sql.ErrNoRows) {
// Fresh database: create the store's tables at the target version
if err := create(tx); err != nil {
return fmt.Errorf("cannot create %s schema: %w", store, err)
}
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
return fmt.Errorf("cannot write %s schema version: %w", store, err)
}
return tx.Commit()
} else if err != nil {
return fmt.Errorf("cannot read %s schema version: %w", store, err)
}
if version == targetVersion {
return tx.Commit()
}
if version > targetVersion {
return fmt.Errorf("unexpected %s schema version %d, this version of ntfy supports up to %d", store, version, targetVersion)
}
for v := version; v < targetVersion; v++ {
migrate, ok := migrations[v]
if !ok {
return fmt.Errorf("cannot find %s migration step from version %d to %d", store, v, v+1)
}
log.Tag(tag).Info("Migrating %s database schema: from %d to %d", store, v, v+1)
if err := migrate(tx); err != nil {
return fmt.Errorf("%s migration step from version %d to %d failed: %w", store, v, v+1, err)
}
}
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
return fmt.Errorf("cannot write %s schema version: %w", store, err)
}
return tx.Commit()
}
func createVersionTableQuery(dialect Dialect) string {
if dialect == Postgres {
return postgresCreateVersionTableQuery
}
return sqliteCreateVersionTableQuery
}
func readVersion(tx *sql.Tx, dialect Dialect, store string) (version int, err error) {
if dialect == Postgres {
err = tx.QueryRow(postgresSelectVersionQuery, store).Scan(&version)
} else {
err = tx.QueryRow(sqliteSelectVersionQuery).Scan(&version)
}
return
}
func writeVersion(tx *sql.Tx, dialect Dialect, store string, version int) error {
var err error
if dialect == Postgres {
_, err = tx.Exec(postgresUpsertVersionQuery, store, version)
} else {
_, err = tx.Exec(sqliteUpsertVersionQuery, version)
}
return err
}
+190
View File
@@ -0,0 +1,190 @@
package schema_test
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/db/schema"
dbtest "heckel.io/ntfy/v2/db/test"
_ "github.com/mattn/go-sqlite3"
)
const testCreateQuery = `CREATE TABLE IF NOT EXISTS things (id TEXT PRIMARY KEY, name TEXT NOT NULL)`
func testCreate(tx *sql.Tx) error {
_, err := tx.Exec(testCreateQuery)
return err
}
func openTestPostgres(t *testing.T) *sql.DB {
t.Helper()
host, err := pg.Open(dbtest.CreateTestPostgresSchema(t))
require.Nil(t, err)
t.Cleanup(func() { host.DB.Close() })
return host.DB
}
func openTestSQLite(t *testing.T) *sql.DB {
t.Helper()
d, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db"))
require.Nil(t, err)
t.Cleanup(func() { d.Close() })
return d
}
func forEachDialect(t *testing.T, f func(t *testing.T, d *sql.DB, dialect schema.Dialect)) {
t.Run("postgres", func(t *testing.T) {
f(t, openTestPostgres(t), schema.Postgres)
})
t.Run("sqlite", func(t *testing.T) {
f(t, openTestSQLite(t), schema.SQLite)
})
}
func TestMigrate_FreshCreate(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
// A fresh database jumps straight to the target version; migration steps are not consulted
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
require.Nil(t, err)
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
// Idempotent: a second node boots against the migrated schema
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
})
}
func TestMigrate_AppliesMigrationSteps(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
// A newer version of the code migrates 1 -> 3 step by step, in order
migrations := map[int]schema.MigrateFunc{
1: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
return err
},
2: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN size INT NOT NULL DEFAULT 0`)
return err
},
}
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, migrations))
_, err := d.Exec(`INSERT INTO things (id, name, color, size) VALUES ('b', 'thing b', 'red', 2)`)
require.Nil(t, err)
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
})
}
func TestMigrate_ClosureCarriesConfig(t *testing.T) {
// Migrations needing config take it via closure at map-construction time; there is no
// params plumbing in the framework itself
migrationsFor := func(defaultName string) map[int]schema.MigrateFunc {
return map[int]schema.MigrateFunc{
1: schema.AsMigrateFunc(fmt.Sprintf(`ALTER TABLE things ADD COLUMN nick TEXT NOT NULL DEFAULT '%s'`, defaultName)),
}
}
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
require.Nil(t, err)
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, migrationsFor("configured-default")))
var nick string
require.Nil(t, d.QueryRow(`SELECT nick FROM things WHERE id = 'a'`).Scan(&nick))
require.Equal(t, "configured-default", nick)
})
}
func TestMigrate_InvalidDialect(t *testing.T) {
d := openTestSQLite(t)
err := schema.Migrate(d, schema.Dialect(99), "things", 1, testCreate, nil)
require.Error(t, err)
}
func TestMigrate_RefusesFutureVersion(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, map[int]schema.MigrateFunc{}))
err := schema.Migrate(d, dialect, "things", 1, testCreate, nil)
require.Error(t, err)
})
}
func TestMigrate_MissingStepFails(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
err := schema.Migrate(d, dialect, "things", 3, testCreate, nil) // No step 1 -> 2 registered
require.Error(t, err)
})
}
func TestMigrate_StoresAreIndependent(t *testing.T) {
// Postgres only: stores share one database, tracked as rows in schema_version. On SQLite
// every store has its own database file, so independence is by file.
d := openTestPostgres(t)
require.Nil(t, schema.Migrate(d, schema.Postgres, "things", 1, testCreate, nil))
require.Nil(t, schema.Migrate(d, schema.Postgres, "gadgets", 4, func(tx *sql.Tx) error {
_, err := tx.Exec(`CREATE TABLE IF NOT EXISTS gadgets (id TEXT PRIMARY KEY)`)
return err
}, nil))
require.Equal(t, 1, storeVersion(t, d, schema.Postgres, "things"))
require.Equal(t, 4, storeVersion(t, d, schema.Postgres, "gadgets"))
}
func TestMigrate_SQLiteReadsExistingSchemaVersionTable(t *testing.T) {
// Existing ntfy SQLite databases (message, user, webpush) track their version in a
// schemaVersion (id, version) table keyed by id = 1; the framework uses that table as-is
// on SQLite, so existing databases migrate without any adoption step
d := openTestSQLite(t)
_, err := d.Exec(testCreateQuery)
require.Nil(t, err)
_, err = d.Exec(`CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`)
require.Nil(t, err)
_, err = d.Exec(`INSERT INTO schemaVersion VALUES (1, 1)`)
require.Nil(t, err)
migrations := map[int]schema.MigrateFunc{
1: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
return err
},
}
require.Nil(t, schema.Migrate(d, schema.SQLite, "things", 2, testCreate, migrations))
_, err = d.Exec(`INSERT INTO things (id, name, color) VALUES ('a', 'thing a', 'red')`)
require.Nil(t, err)
require.Equal(t, 2, storeVersion(t, d, schema.SQLite, "things"))
}
func TestMigrate_ConcurrentFreshCreate(t *testing.T) {
// Postgres only: concurrent cold-boots must not race on DDL (CREATE TABLE IF NOT EXISTS is
// not atomic); Migrate serializes via an advisory lock. SQLite has a single writer.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
const n = 8
errs := make(chan error, n)
for i := 0; i < n; i++ {
go func() {
host, err := pg.Open(schemaDSN)
if err != nil {
errs <- err
return
}
defer host.DB.Close()
errs <- schema.Migrate(host.DB, schema.Postgres, "things", 1, testCreate, nil)
}()
}
for i := 0; i < n; i++ {
require.Nil(t, <-errs)
}
}
func storeVersion(t *testing.T, d *sql.DB, dialect schema.Dialect, store string) int {
t.Helper()
var version int
if dialect == schema.Postgres {
require.Nil(t, d.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version), fmt.Sprintf("store %s", store))
} else {
require.Nil(t, d.QueryRow(`SELECT version FROM schemaVersion WHERE id = 1`).Scan(&version), fmt.Sprintf("store %s", store))
}
return version
}
+31
View File
@@ -0,0 +1,31 @@
package schema
import "database/sql"
// Dialect selects the SQL flavor Migrate speaks to the version table.
type Dialect int
// Supported dialects; SQLite is the zero value
const (
SQLite Dialect = iota
Postgres
)
// MigrateFunc applies one schema change inside the setup transaction: the initial creation of
// a store's tables, or one step upgrading a store from version N to N+1. Migrations needing
// config capture it via closure, e.g. func migrations(cacheDuration time.Duration) map[int]MigrateFunc.
type MigrateFunc func(tx *sql.Tx) error
// AsMigrateFunc converts a simple query to a migration function
func AsMigrateFunc(query string) MigrateFunc {
return func(tx *sql.Tx) error {
_, err := tx.Exec(query)
return err
}
}
// NopMigrateFunc is a migration step that does nothing, for versions where a dialect has no
// work to do (e.g. when only the other dialect's schema changed).
func NopMigrateFunc(_ *sql.Tx) error {
return nil
}
+185
View File
@@ -0,0 +1,185 @@
package dbtest
import (
"database/sql"
"fmt"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Querier is the subset of *sql.DB / *db.DB needed to introspect a schema.
type Querier interface {
Query(query string, args ...any) (*sql.Rows, error)
}
// SQLiteSchema returns a normalized, comparable description of the database schema: tables
// with their columns, named indexes, and foreign keys. Column order, declared type spelling
// (INT vs INTEGER) and default values are not part of the description, so the schema produced
// by a migration chain can be compared to a freshly created one.
func SQLiteSchema(t testing.TB, d Querier) string {
t.Helper()
lines := make([]string, 0)
for _, table := range sqliteTables(t, d) {
lines = append(lines, "table "+table)
lines = append(lines, sqliteColumns(t, d, table)...)
lines = append(lines, sqliteForeignKeys(t, d, table)...)
lines = append(lines, sqliteIndexes(t, d, table)...)
}
return strings.Join(lines, "\n")
}
// PostgresSchema is SQLiteSchema's PostgreSQL counterpart, describing the current schema's
// tables, columns, constraints and indexes in a normalized, comparable way.
func PostgresSchema(t testing.TB, d Querier) string {
t.Helper()
lines := make([]string, 0)
for _, table := range postgresTables(t, d) {
lines = append(lines, "table "+table)
lines = append(lines, postgresColumns(t, d, table)...)
}
lines = append(lines, postgresConstraints(t, d)...)
lines = append(lines, postgresIndexes(t, d)...)
return strings.Join(lines, "\n")
}
func sqliteTables(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
}
func sqliteColumns(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA table_info(%q)`, table))
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var dflt sql.NullString
require.Nil(t, rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk))
typ = strings.ToUpper(typ)
if typ == "INT" { // INT and INTEGER are the same affinity; migrations spell them inconsistently
typ = "INTEGER"
}
lines = append(lines, fmt.Sprintf(" col %s %s notnull=%d pk=%d", name, typ, notNull, pk))
}
require.Nil(t, rows.Err())
sort.Strings(lines)
return lines
}
func sqliteForeignKeys(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA foreign_key_list(%q)`, table))
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var id, seq int
var refTable, from, onUpdate, onDelete, match string
var to sql.NullString // NULL when referencing the parent's primary key implicitly
require.Nil(t, rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &match))
lines = append(lines, fmt.Sprintf(" fk %s -> %s(%s) on_delete=%s", from, refTable, to.String, onDelete))
}
require.Nil(t, rows.Err())
sort.Strings(lines)
return lines
}
func sqliteIndexes(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA index_list(%q)`, table))
require.Nil(t, err)
type index struct {
name string
unique, partial int
}
indexes := make([]index, 0)
for rows.Next() {
var seq, unique, partial int
var name, origin string
require.Nil(t, rows.Scan(&seq, &name, &unique, &origin, &partial))
// Skip auto-indexes backing PRIMARY KEY/UNIQUE table constraints; those are described
// by the column and constraint listings already
if strings.HasPrefix(name, "sqlite_autoindex_") {
continue
}
indexes = append(indexes, index{name, unique, partial})
}
require.Nil(t, rows.Err())
require.Nil(t, rows.Close())
lines := make([]string, 0, len(indexes))
for _, idx := range indexes {
cols := queryStrings(t, d, fmt.Sprintf(`SELECT name FROM pragma_index_info(%q) ORDER BY seqno`, idx.name))
lines = append(lines, fmt.Sprintf(" index %s unique=%d partial=%d cols=(%s)", idx.name, idx.unique, idx.partial, strings.Join(cols, ",")))
}
sort.Strings(lines)
return lines
}
func postgresTables(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name`)
}
func postgresColumns(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`, table)
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var name, typ, nullable string
require.Nil(t, rows.Scan(&name, &typ, &nullable))
lines = append(lines, fmt.Sprintf(" col %s %s nullable=%s", name, typ, nullable))
}
require.Nil(t, rows.Err())
return lines
}
func postgresConstraints(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `
SELECT 'constraint ' || conrelid::regclass::text || ': ' || pg_get_constraintdef(oid)
FROM pg_constraint
WHERE connamespace = current_schema()::regnamespace
ORDER BY 1
`)
}
func postgresIndexes(t testing.TB, d Querier) []string {
t.Helper()
rows, err := d.Query(`SELECT indexname, indexdef, schemaname FROM pg_indexes WHERE schemaname = current_schema() ORDER BY indexname`)
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var name, def, schema string
require.Nil(t, rows.Scan(&name, &def, &schema))
// The index definition qualifies the table with the (test-specific) schema name; strip
// it so snapshots from different test schemas compare equal
def = strings.ReplaceAll(def, schema+".", "")
lines = append(lines, "index "+def)
}
require.Nil(t, rows.Err())
return lines
}
func queryStrings(t testing.TB, d Querier, query string) []string {
t.Helper()
rows, err := d.Query(query)
require.Nil(t, err)
defer rows.Close()
values := make([]string, 0)
for rows.Next() {
var value string
require.Nil(t, rows.Scan(&value))
values = append(values, value)
}
require.Nil(t, rows.Err())
return values
}
+8 -7
View File
@@ -379,7 +379,7 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
</div>
</div>
<div class="cg-panel" id="cg-panel-database">
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql-experimental" target="_blank">PostgreSQL</a> for details.</div>
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql" target="_blank">PostgreSQL</a> for details.</div>
<div class="cg-field">
<label>Database URL</label>
<input type="text" data-key="database-url" placeholder="postgres://user:pass@host:5432/ntfy">
@@ -417,7 +417,7 @@ no external dependencies:
* `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth.
* `web-push-file`: Database file for [web push](#web-push) subscriptions.
### PostgreSQL (EXPERIMENTAL)
### PostgreSQL
As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the
`database-url` option to a PostgreSQL connection string.
@@ -1656,7 +1656,7 @@ a database to keep track of the browser's subscriptions, and an admin email addr
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url`
(see [PostgreSQL database](#postgresql-experimental)).
(see [PostgreSQL database](#postgresql)).
Limitations:
@@ -2129,7 +2129,7 @@ chain.
The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to
4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix.
#### Ban-feed
### Ban-feed
In addition to the fail2ban setup above, ntfy can detect abusive visitors itself and write their IP
addresses to a file for fail2ban to ban from. ntfy keeps a per-prefix weighted "strike" budget, and
each rejected request costs strikes based on its response code -- the ntfy error code, or its HTTP
@@ -2225,13 +2225,14 @@ See [Installation for Docker](install.md#docker) for an example of how this coul
If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to
create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).
To configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated
To configure the metrics endpoint, either set `enable-metrics`, or set the `metrics-listen-http` option to a dedicated
listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
doing, and/or secure access to the endpoint in your reverse proxy.
- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly
enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
- `metrics-listen-http` moves the metrics endpoint to a dedicated `[IP]:port`, e.g. "10.0.1.1:9090" or ":9090". It
implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default ntfy server
does not serve /metrics, even if `enable-metrics` is also set.
=== "server.yml (Using default port)"
```yaml
+38 -38
View File
@@ -34,37 +34,37 @@ as a service starting at boot time.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.tar.gz
tar zxvf ntfy_2.26.3_linux_amd64.tar.gz
sudo cp -a ntfy_2.26.3_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.tar.gz
tar zxvf ntfy_2.27.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.27.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.tar.gz
tar zxvf ntfy_2.26.3_linux_armv6.tar.gz
sudo cp -a ntfy_2.26.3_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.tar.gz
tar zxvf ntfy_2.27.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.27.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.tar.gz
tar zxvf ntfy_2.26.3_linux_armv7.tar.gz
sudo cp -a ntfy_2.26.3_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.tar.gz
tar zxvf ntfy_2.27.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.27.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.tar.gz
tar zxvf ntfy_2.26.3_linux_arm64.tar.gz
sudo cp -a ntfy_2.26.3_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.tar.gz
tar zxvf ntfy_2.27.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.27.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.26.3_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.27.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv6"
```bash
sudo mv ntfy_2.26.3_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.27.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.26.3_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.27.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "arm64"
```bash
sudo mv ntfy_2.26.3_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.27.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
@@ -118,25 +118,25 @@ Install the ntfy server service script:
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.26.3_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.27.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv6"
```bash
sudo mv ntfy_2.26.3_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.27.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.26.3_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.27.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "arm64"
```bash
sudo mv ntfy_2.26.3_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.27.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
@@ -204,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -212,7 +212,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -220,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -228,7 +228,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -238,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -301,18 +301,18 @@ pkg install go-ntfy
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_darwin_all.tar.gz > ntfy_2.26.3_darwin_all.tar.gz
tar zxvf ntfy_2.26.3_darwin_all.tar.gz
sudo cp -a ntfy_2.26.3_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_darwin_all.tar.gz > ntfy_2.27.0_darwin_all.tar.gz
tar zxvf ntfy_2.27.0_darwin_all.tar.gz
sudo cp -a ntfy_2.27.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.26.3_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.27.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -333,7 +333,7 @@ brew install ntfy
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
To install, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_windows_amd64.zip),
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
+1 -1
View File
@@ -84,13 +84,13 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)
- [ex_ntfy](https://github.com/houllette/ex_ntfy) - Elixir SDK covering publishing, polling, and streaming subscriptions for ntfy servers (Elixir)
## CLIs + GUIs
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#)
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
+4 -1
View File
@@ -3227,7 +3227,10 @@ your templates there first ([example for Grafana alert](https://repeatit.io/#/sh
!!! info
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
a template that loops too long is stopped and rejected with an HTTP 400 error.
a template that loops too long is stopped and rejected with an HTTP 400 error. Templates are
limited to 32 KB in size, `printf` widths and precisions must be below 1000 (`%999d` is
allowed, `%1000d` is not), including the `%*d` form that takes the width from an argument, and
`indent`/`nindent` are limited to 100 spaces.
### Template functions
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
+68 -39
View File
@@ -4,14 +4,73 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Current stable releases
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.26.3 | Jul 20, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.7.0 | May 30, 2026 |
| Component | Version | Release date |
|------------------|---------|---------------|
| ntfy server | v2.27.0 | Aug 4, 2026 |
| ntfy Android app | v1.25.2 | July 23, 2026 |
| ntfy iOS app | v1.7.0 | May 30, 2026 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
### ntfy server v2.27.0
Released August 4, 2026
This release lets you sign in with your verified email address instead of your username, which should help if you ever
signed up with an email and then forgot which username you picked. It also hardens the message templating engine against
a few ways a small template could eat a lot of memory, and it drops the "experimental" label from
[PostgreSQL support](config.md#postgresql), which has been running ntfy.sh for a while now.
I also did a bunch of refactoring in, mostly in preparation for being able to cluster ntfy nodes and scale the service
horizontally. It'll be a while until then, ... baby steps.
**Security:**
* Limit message templates (`Template: yes`) to 32 KB, limit `printf` widths and precisions to below 1000, and limit `indent`/`nindent` to 100 spaces, preventing excessive memory use from a single small template
* Exclude secrets from the config hash served to the web app, preventing a rather theoretical information leak
**Features:**
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
**Bug fixes + maintenance:**
* Fix Twilio phone calls and phone number verifications failing silently when Twilio rejected the request, and move the Twilio integration into its own `twilio` package
* Move the Prometheus metrics into a dedicated `metrics` package
* Message cache databases from ntfy older than v1.10.0 (November 2021) can no longer be migrated; upgrade via an older ntfy version first, or delete the cache database
* Fix `user_phone` table in the SQLite user database referencing a dropped table after the v2.14 schema migration; repaired automatically by a new migration
## ntfy Android v1.25.2
Released July 23, 2026
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
once connectivity returns.
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
**Features:**
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
**Bug fixes + maintenance:**
* Fix the "connection lost" alert briefly disappearing and re-firing when roaming between networks (e.g. Wi-Fi to cellular), by no longer cancelling it during the transient no-network gap of a handover
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
### ntfy server v2.26.3
Released July 20, 2026
@@ -35,7 +94,7 @@ and a fix that strips unsafe URL protocols from rendered Markdown.
**Security:**
* Prevent a CPU denial of service via message templates (`Template: yes`) ([GHSA-rhwf-xgc9-m9fp](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-rhwf-xgc9-m9fp), [#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881) for reporting)
* Prevent a CPU denial of service via message templates (`Template: yes`) ([#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881), [@5ud0er](https://github.com/5ud0er) and [@jvoisin](https://github.com/jvoisin) for reporting)
**Features:**
@@ -268,7 +327,7 @@ to the primary until the replica recovers.
**Features:**
* Support [PostgreSQL read replicas](config.md#postgresql-experimental) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
* Support [PostgreSQL read replicas](config.md#postgresql) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
**Bug fixes + maintenance:**
@@ -280,7 +339,7 @@ to the primary until the replica recovers.
Released March 7, 2026
This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql-experimental).
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql).
The code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents,
went through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests,
@@ -291,7 +350,7 @@ if things are working (or not working). There is a [one-off migration tool](http
**Features:**
* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
* Add experimental [PostgreSQL support](config.md#postgresql) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
**Bug fixes + maintenance:**
@@ -2019,36 +2078,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy Android v1.25.1 (UNRELEASED)
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
once connectivity returns.
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
**Features:**
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
**Bug fixes + maintenance:**
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
### ntfy iOS app v1.8.0 (UNRELEASED)
**Features:**
+28 -28
View File
@@ -3,23 +3,23 @@ module heckel.io/ntfy/v2
go 1.25.8
require (
cloud.google.com/go/firestore v1.22.0 // indirect
cloud.google.com/go/storage v1.63.0 // indirect
cloud.google.com/go/firestore v1.24.0 // indirect
cloud.google.com/go/storage v1.64.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.24.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gabriel-vasile/mimetype v1.4.15
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.47
github.com/mattn/go-sqlite3 v1.14.49
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.53.0
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
google.golang.org/api v0.287.0
google.golang.org/api v0.291.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -28,29 +28,29 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.20.0
firebase.google.com/go/v4 v4.21.0
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.10.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_golang v1.24.1
github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/sys v0.46.0
golang.org/x/text v0.39.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
)
require (
cel.dev/expr v0.25.2 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth v0.22.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.11.0 // indirect
cloud.google.com/go/longrunning v1.1.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
cloud.google.com/go/iam v1.12.0 // indirect
cloud.google.com/go/longrunning v1.2.0 // indirect
cloud.google.com/go/monitoring v1.30.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -62,15 +62,15 @@ require (
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -79,7 +79,7 @@ require (
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
@@ -94,12 +94,12 @@ require (
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/net v0.57.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/grpc v1.82.0 // indirect
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+62 -62
View File
@@ -2,40 +2,40 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ=
cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE=
cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ=
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE=
cloud.google.com/go/firestore v1.24.0 h1:x0Z3hrgjYgo2wI9whuBRQcNc2hYwzZDQy/7pkUXbXcs=
cloud.google.com/go/firestore v1.24.0/go.mod h1:5aojyjN4olKUnBZDCRWwM+NsdrrCX3t1qfyERZGOonM=
cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk=
cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY=
cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q=
cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA=
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg=
cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ=
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8=
firebase.google.com/go/v4 v4.21.0 h1:HBZV4jrLtFYj8EwWyqEZOuRLfkfkV2bpnfyyXHOhPxY=
firebase.google.com/go/v4 v4.21.0/go.mod h1:CDumIdA5oTiyDpLNVcQoW8ZrB5CTgyE2D45DuENIABg=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 h1:c/Ivw7FuawPLfrr+zB0LZKeCchO2cAHQpF2qZ6OV7rQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0/go.mod h1:Zba7lknY/d78oxbKqFTmCsaGwfpzeJ3ktrrLXtnTV6g=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0 h1:xTXsqDOj5k9mK3VVWHYUryryJCIdYfXxdjKFwpzINUw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 h1:18FRm6ZcN/x9+ZmhMr96hLcTtlLn2/gHPuDLVeg7XcY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
@@ -68,13 +68,13 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -96,10 +96,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4=
github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -112,16 +112,16 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -135,12 +135,12 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -195,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -211,8 +211,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -222,8 +222,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -236,8 +236,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -247,8 +247,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -260,8 +260,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -274,18 +274,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4=
google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ=
google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0=
google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d h1:33JLrUF0lFT31667SAtJzZAjLewV0ew5Mizks4caz0A=
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d/go.mod h1:I7vGRdTamb7ukERkgP9I+0e4p21O4ak3cM7ICA3krg8=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+1
View File
@@ -17,6 +17,7 @@ import (
const (
tagMessageCache = "message_cache"
schemaStore = "message" // Store name in the schema_version table (see db/schema)
)
var errNoRows = errors.New("no rows found")
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
)
// PostgreSQL runtime query constants
@@ -102,7 +103,7 @@ var postgresQueries = queries{
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.
func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
if err := setupPostgres(d.Primary()); err != nil {
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
return nil, err
}
return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil
+10 -64
View File
@@ -1,16 +1,13 @@
package message
import (
"database/sql"
"fmt"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/db/schema"
)
// Initial PostgreSQL schema
const (
postgresCreateTablesQuery = `
postgresCurrentSchemaVersion = 15
postgresCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS message (
id BIGSERIAL PRIMARY KEY,
mid TEXT NOT NULL,
@@ -50,21 +47,9 @@ const (
value BIGINT
);
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
`
)
// PostgreSQL schema management queries
const (
postgresCurrentSchemaVersion = 15
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)`
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'message'`
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'`
)
// PostgreSQL schema migrations
const (
// 14 -> 15
@@ -73,51 +58,12 @@ const (
`
)
var postgresMigrations = map[int]func(d *sql.DB) error{
14: postgresMigrateFrom14,
}
var (
postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQuery)
func setupPostgres(d *sql.DB) error {
var schemaVersion int
if err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
return setupNewPostgresDB(d)
} else if schemaVersion == postgresCurrentSchemaVersion {
return nil
} else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
// postgresMigrations maps a schema version to the migration upgrading it to the next
// version. Always append migrations at the end, never insert in the middle.
postgresMigrations = map[int]schema.MigrateFunc{
14: schema.AsMigrateFunc(postgresMigrate14To15CreateIndexQuery),
}
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
fn, ok := postgresMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(d); err != nil {
return err
}
}
return nil
}
func postgresMigrateFrom14(d *sql.DB) error {
log.Tag(tagMessageCache).Info("Migrating message cache database schema: from 14 to 15")
return db.ExecTx(d, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresMigrate14To15CreateIndexQuery); err != nil {
return err
}
if _, err := tx.Exec(postgresUpdateSchemaVersionQuery, 15); err != nil {
return err
}
return nil
})
}
func setupNewPostgresDB(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
)
+76
View File
@@ -0,0 +1,76 @@
package message_test
import (
"testing"
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
func TestPostgresStore_Migration_From14(t *testing.T) {
// A pre-framework database at version 14: full v14 schema, version tracked in the
// hand-rolled schema_version table, and no idx_message_attachment_expires yet
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(`
CREATE TABLE message (
id BIGSERIAL PRIMARY KEY,
mid TEXT NOT NULL,
sequence_id TEXT NOT NULL,
time BIGINT NOT NULL,
event TEXT NOT NULL,
expires BIGINT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size BIGINT NOT NULL,
attachment_expires BIGINT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,
sender TEXT NOT NULL,
user_id TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_message_mid ON message (mid);
CREATE INDEX idx_message_sequence_id ON message (sequence_id);
CREATE INDEX idx_message_topic_published_time ON message (topic, published, time, id);
CREATE INDEX idx_message_published_expires ON message (published, expires);
CREATE INDEX idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';
CREATE INDEX idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);
CREATE TABLE message_stats (key TEXT PRIMARY KEY, value BIGINT);
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schema_version (store, version) VALUES ('message', 14);
`)
require.Nil(t, err)
store, err := message.NewPostgresStore(testDB, 0, 0)
require.Nil(t, err)
// The 14 -> 15 step ran: version bumped, partial index created
var version int
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'message'`).Scan(&version))
require.Equal(t, 15, version)
var indexCount int
require.Nil(t, testDB.QueryRow(`SELECT COUNT(*) FROM pg_indexes WHERE indexname = 'idx_message_attachment_expires' AND schemaname = current_schema()`).Scan(&indexCount))
require.Equal(t, 1, indexCount)
// And the store works
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "hi there")))
messages, err := store.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Len(t, messages, 1)
// The migrated database must be structurally identical to a freshly created one
freshDB := dbtest.CreateTestPostgres(t)
_, err = message.NewPostgresStore(freshDB, 0, 0)
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
}
+5 -1
View File
@@ -9,6 +9,7 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/util"
)
@@ -113,7 +114,10 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration
if err != nil {
return nil, err
}
if err := setupSQLite(d, startupQueries, cacheDuration); err != nil {
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
return nil, err
}
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations(cacheDuration)); err != nil {
return nil, err
}
return newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil
+30 -283
View File
@@ -2,16 +2,15 @@ package message
import (
"database/sql"
"fmt"
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/db/schema"
)
// Initial SQLite schema
const (
sqliteCreateTablesQuery = `
sqliteCurrentSchemaVersion = 15
sqliteCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
@@ -55,29 +54,9 @@ const (
`
)
// Schema version management for SQLite
// Schema migrations for SQLite. Databases older than schema version 1 (ntfy < v1.10.0,
// November 2021) can no longer be migrated.
const (
sqliteCurrentSchemaVersion = 15
sqliteCreateSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
`
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
// Schema migrations for SQLite
const (
// 0 -> 1
sqliteMigrate0To1AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
`
// 1 -> 2
sqliteMigrate1To2AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
@@ -193,67 +172,35 @@ const (
)
var (
sqliteMigrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
0: sqliteMigrateFrom0,
1: sqliteMigrateFrom1,
2: sqliteMigrateFrom2,
3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
6: sqliteMigrateFrom6,
7: sqliteMigrateFrom7,
8: sqliteMigrateFrom8,
9: sqliteMigrateFrom9,
10: sqliteMigrateFrom10,
11: sqliteMigrateFrom11,
12: sqliteMigrateFrom12,
13: sqliteMigrateFrom13,
14: sqliteMigrateFrom14,
}
sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQuery)
)
func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
return err
}
// If 'messages' table does not exist, this must be a new database
var messagesCount int
if err := db.QueryRow(sqliteSelectMessagesCountQuery).Scan(&messagesCount); err != nil {
return setupNewSQLite(db)
}
// If 'messages' table exists (schema >= 0), check 'schemaVersion' table
var schemaVersion int
db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) // Error means schema version is zero!
// Do migrations
if schemaVersion == sqliteCurrentSchemaVersion {
return nil
} else if schemaVersion > sqliteCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
}
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
fn, ok := sqliteMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db, cacheDuration); err != nil {
// sqliteMigrations returns the migration steps, keyed by the version they upgrade FROM. The
// cache duration is carried into the 9 -> 10 step via closure (it backfills "expires" from it).
// Always append migrations at the end, never insert in the middle.
func sqliteMigrations(cacheDuration time.Duration) map[int]schema.MigrateFunc {
return map[int]schema.MigrateFunc{
1: schema.AsMigrateFunc(sqliteMigrate1To2AlterMessagesTableQuery),
2: schema.AsMigrateFunc(sqliteMigrate2To3AlterMessagesTableQuery),
3: schema.AsMigrateFunc(sqliteMigrate3To4AlterMessagesTableQuery),
4: schema.AsMigrateFunc(sqliteMigrate4To5AlterMessagesTableQuery),
5: schema.AsMigrateFunc(sqliteMigrate5To6AlterMessagesTableQuery),
6: schema.AsMigrateFunc(sqliteMigrate6To7AlterMessagesTableQuery),
7: schema.AsMigrateFunc(sqliteMigrate7To8AlterMessagesTableQuery),
8: schema.AsMigrateFunc(sqliteMigrate8To9AlterMessagesTableQuery),
9: func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
return err
}
_, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds()))
return err
}
},
10: schema.AsMigrateFunc(sqliteMigrate10To11AlterMessagesTableQuery),
11: schema.AsMigrateFunc(sqliteMigrate11To12AlterMessagesTableQuery),
12: schema.AsMigrateFunc(sqliteMigrate12To13AlterMessagesTableQuery),
13: schema.AsMigrateFunc(sqliteMigrate13To14AlterMessagesTableQuery),
14: schema.NopMigrateFunc, // Corresponds to Postgres migration
}
return nil
}
func setupNewSQLite(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
@@ -264,203 +211,3 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
}
return nil
}
func sqliteMigrateFrom0(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom1(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom2(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom3(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom4(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom5(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom6(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom7(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom8(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom9(sqlDB *sql.DB, cacheDuration time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom10(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom11(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom12(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom13(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil {
return err
}
return nil
})
}
// sqliteMigrateFrom14 is a no-op; the corresponding Postgres migration adds
// idx_message_attachment_expires, which SQLite already has from the initial schema.
func sqliteMigrateFrom14(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 14 to 15")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 15); err != nil {
return err
}
return nil
})
}
+14 -40
View File
@@ -9,50 +9,11 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
func TestSqliteStore_Migration_From0(t *testing.T) {
filename := newSqliteTestStoreFile(t)
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 0" schema
_, err = db.Exec(`
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(20) PRIMARY KEY,
time INT NOT NULL,
topic VARCHAR(64) NOT NULL,
message VARCHAR(1024) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
COMMIT;
`)
require.Nil(t, err)
// Insert a bunch of messages
for i := 0; i < 10; i++ {
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
require.Nil(t, err)
}
require.Nil(t, db.Close())
// Create store to trigger migration
s := newSqliteTestStoreFromFile(t, filename, "")
checkSqliteSchemaVersion(t, filename)
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
require.Equal(t, "some message 5", messages[5].Message)
require.Equal(t, "", messages[5].Title)
require.Nil(t, messages[5].Tags)
require.Equal(t, 0, messages[5].Priority)
}
func TestSqliteStore_Migration_From1(t *testing.T) {
filename := newSqliteTestStoreFile(t)
db, err := sql.Open("sqlite3", filename)
@@ -90,6 +51,19 @@ func TestSqliteStore_Migration_From1(t *testing.T) {
s := newSqliteTestStoreFromFile(t, filename, "")
checkSqliteSchemaVersion(t, filename)
// The migrated database must be structurally identical to a freshly created one
freshFile := newSqliteTestStoreFile(t)
fresh, err := message.NewSQLiteStore(freshFile, "", time.Hour, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { fresh.Close() })
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
// Add delayed message
delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message")
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
+107
View File
@@ -0,0 +1,107 @@
// Package metrics defines the Prometheus metrics exposed by the ntfy server, and registers them
// with the default Prometheus registry on import. It is decoupled from the ntfy server, so that
// call sites can update metrics without depending on the server package.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
)
// Collectors for all metrics exposed by the server.
//
// These are never nil, so that call sites can update them unconditionally. If metrics are
// disabled, the server never mounts the /metrics handler, and the values are simply never read.
var (
MessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_success",
})
MessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_failure",
})
MessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_messages_cached_total",
})
MessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_message_publish_duration_ms",
})
FirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_success",
})
FirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_failure",
})
EmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_success",
})
EmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_failure",
})
EmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_success",
})
EmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_failure",
})
CallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_success",
})
CallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_failure",
})
UnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_unifiedpush_published_success",
})
MatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_success",
})
MatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_failure",
})
AttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_attachments_total_size",
})
Visitors = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_visitors_total",
})
Users = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_users_total",
})
Subscribers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_subscribers_total",
})
Topics = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_topics_total",
})
HTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_http_requests_total",
}, []string{"http_code", "ntfy_code", "http_method"})
)
// init registers all collectors with the default Prometheus registry. Registration is
// unconditional: the collectors are only ever exposed if the server mounts the /metrics handler,
// so there is nothing to be gained by tying registration to the config.
func init() {
prometheus.MustRegister(
MessagesPublishedSuccess,
MessagesPublishedFailure,
MessagesCached,
MessagePublishDurationMillis,
FirebasePublishedSuccess,
FirebasePublishedFailure,
EmailsPublishedSuccess,
EmailsPublishedFailure,
EmailsReceivedSuccess,
EmailsReceivedFailure,
CallsMadeSuccess,
CallsMadeFailure,
UnifiedPushPublishedSuccess,
MatrixPublishedSuccess,
MatrixPublishedFailure,
AttachmentsTotalSize,
Visitors,
Users,
Subscribers,
Topics,
HTTPRequests,
)
}
+58
View File
@@ -0,0 +1,58 @@
package metrics
import (
"sort"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
// expectedMetricNames is the exact set of metrics the server exposes. These names are a public
// contract: renaming or dropping one silently breaks existing dashboards and alerts.
var expectedMetricNames = []string{
"ntfy_attachments_total_size",
"ntfy_calls_made_failure",
"ntfy_calls_made_success",
"ntfy_emails_received_failure",
"ntfy_emails_received_success",
"ntfy_emails_sent_failure",
"ntfy_emails_sent_success",
"ntfy_firebase_published_failure",
"ntfy_firebase_published_success",
"ntfy_http_requests_total",
"ntfy_matrix_published_failure",
"ntfy_matrix_published_success",
"ntfy_message_publish_duration_ms",
"ntfy_messages_cached_total",
"ntfy_messages_published_failure",
"ntfy_messages_published_success",
"ntfy_subscribers_total",
"ntfy_topics_total",
"ntfy_unifiedpush_published_success",
"ntfy_users_total",
"ntfy_visitors_total",
}
func TestRegisteredMetricNames(t *testing.T) {
HTTPRequests.WithLabelValues("200", "20000", "GET").Inc()
families, err := prometheus.DefaultGatherer.Gather()
require.Nil(t, err)
names := make([]string, 0)
for _, family := range families {
if strings.HasPrefix(family.GetName(), "ntfy_") {
names = append(names, family.GetName())
}
}
sort.Strings(names)
require.Equal(t, expectedMetricNames, names)
}
func TestCollectors_NeverNil(t *testing.T) {
// Call sites update metrics unconditionally, even when metrics are disabled, so no collector
// may ever be nil
MessagesPublishedSuccess.Inc()
MessagesCached.Set(1)
HTTPRequests.WithLabelValues("200", "20000", "PUT").Inc()
}
+12 -9
View File
@@ -130,9 +130,9 @@ type Config struct {
AuthFile string
AuthStartupQueries string
AuthDefault user.Permission
AuthUsers []*user.User
AuthUsers []*user.User `hash:"-"`
AuthAccess map[string][]*user.Grant
AuthTokens map[string][]*user.Token
AuthTokens map[string][]*user.Token `hash:"-"`
AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
@@ -153,23 +153,22 @@ type Config struct {
FirebasePollInterval time.Duration
FirebaseQuotaExceededPenaltyDuration time.Duration
UpstreamBaseURL string
UpstreamAccessToken string
UpstreamAccessToken string `hash:"-"`
SMTPSenderAddr string
SMTPSenderUser string
SMTPSenderPass string
SMTPSenderPass string `hash:"-"`
SMTPSenderFrom string
SMTPSenderVerify bool
SMTPServerListen string
SMTPServerDomain string
SMTPServerAddrPrefix string
TwilioAccount string
TwilioAuthToken string
TwilioAuthToken string `hash:"-"`
TwilioPhoneNumber string
TwilioCallsBaseURL string
TwilioVerifyBaseURL string
TwilioVerifyService string
TwilioCallFormat *template.Template
MetricsEnable bool
MetricsListenHTTP string
ProfileListenHTTP string
MessageDelayMin time.Duration
@@ -199,8 +198,8 @@ type Config struct {
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
StripeSecretKey string
StripeWebhookKey string
StripeSecretKey string `hash:"-"`
StripeWebhookKey string `hash:"-"`
StripePriceCacheDuration time.Duration
BillingContact string
EnableSignup bool // Enable creation of accounts via API and UI
@@ -209,7 +208,7 @@ type Config struct {
EnableReservations bool // Allow users with role "user" to own/reserve topics
EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
WebPushPrivateKey string
WebPushPrivateKey string `hash:"-"`
WebPushPublicKey string
WebPushFile string
WebPushEmailAddress string
@@ -343,6 +342,10 @@ func (c *Config) Hash() string {
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldName := t.Field(i).Name
// Secrets must not feed the hash
if t.Field(i).Tag.Get("hash") == "-" {
continue
}
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
if b, err := json.Marshal(field.Interface()); err == nil {
result += fmt.Sprintf("%s:%s|", fieldName, string(b))
+22
View File
@@ -3,6 +3,7 @@ package server_test
import (
"github.com/stretchr/testify/assert"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"testing"
)
@@ -11,3 +12,24 @@ func TestConfig_New(t *testing.T) {
assert.Equal(t, ":80", c.ListenHTTP)
assert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval)
}
func TestConfig_HashExcludesSecrets(t *testing.T) {
// The config hash is served to browsers (ConfigHash, for webapp change detection), so
// secret material must not feed it: a weak secret would otherwise be offline-brute-forceable
// against a publicly visible hash.
conf1 := server.NewConfig()
conf2 := server.NewConfig()
conf2.StripeSecretKey = "sk_live_topsecret"
conf2.StripeWebhookKey = "whsec_topsecret"
conf2.TwilioAuthToken = "twilio-auth-token"
conf2.UpstreamAccessToken = "tk_upstream"
conf2.WebPushPrivateKey = "web-push-private-key"
conf2.SMTPSenderPass = "hunter2"
conf2.AuthUsers = []*user.User{{Name: "phil", Hash: "$2a$10$somebcrypthash"}}
conf2.AuthTokens = map[string][]*user.Token{"phil": {{Value: "tk_secrettoken"}}}
assert.Equal(t, conf1.Hash(), conf2.Hash())
// Non-secret fields must still change the hash
conf3 := server.NewConfig()
conf3.BaseURL = "https://ntfy.example.com"
assert.NotEqual(t, conf1.Hash(), conf3.Hash())
}
+1
View File
@@ -148,6 +148,7 @@ var (
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
+15 -16
View File
@@ -16,22 +16,21 @@ import (
// Log tags
const (
tagStartup = "startup"
tagHTTP = "http"
tagPublish = "publish"
tagSubscribe = "subscribe"
tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email
tagTwilio = "twilio"
tagMessageCache = "message_cache"
tagStripe = "stripe"
tagAccount = "account"
tagManager = "manager"
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
tagWebPush = "webpush"
tagStartup = "startup"
tagHTTP = "http"
tagPublish = "publish"
tagSubscribe = "subscribe"
tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email
tagTwilio = "twilio"
tagStripe = "stripe"
tagAccount = "account"
tagManager = "manager"
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
tagWebPush = "webpush"
)
var (
+89 -76
View File
@@ -37,8 +37,10 @@ import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/metrics"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/twilio"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/webpush"
@@ -60,7 +62,7 @@ type Server struct {
visitors map[string]*visitor // ip:<ip> or user:<user>
ban *ban.Service // Abuse ban-feed; nil when the feature is disabled (no ban file)
firebaseClient *firebaseClient
twilio *twilioClient
twilio *twilio.Client
messages int64 // Total number of messages (persisted if messageCache enabled)
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
userManager *user.Manager // Might be nil!
@@ -112,6 +114,7 @@ var (
apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access"
apiAccountPath = "/v1/account"
apiAccountLoginPath = "/v1/account/login"
apiAccountTokenPath = "/v1/account/token"
apiAccountPasswordPath = "/v1/account/password"
apiAccountSettingsPath = "/v1/account/settings"
@@ -147,12 +150,6 @@ var (
//go:embed docs
docsStaticFs embed.FS
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
//go:embed templates
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
templatesDir = "templates"
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
)
const (
@@ -166,9 +163,6 @@ const (
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
messagesHistoryMax = 10 // Number of message count values to keep in memory
templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
)
// WebSocket constants
@@ -249,6 +243,16 @@ func New(conf *Config) (*Server, error) {
if err != nil {
return nil, err
}
twilioClient := twilio.NewClient(&twilio.Config{
Account: conf.TwilioAccount,
AuthToken: conf.TwilioAuthToken,
PhoneNumber: conf.TwilioPhoneNumber,
CallsBaseURL: conf.TwilioCallsBaseURL,
VerifyBaseURL: conf.TwilioVerifyBaseURL,
VerifyService: conf.TwilioVerifyService,
CallFormat: conf.TwilioCallFormat,
BuildVersion: conf.BuildVersion,
})
var userManager *user.Manager
if conf.AuthFile != "" || pool != nil {
authConfig := &user.Config{
@@ -306,7 +310,7 @@ func New(conf *Config) (*Server, error) {
webPush: wp,
attachment: attachmentStore,
firebaseClient: firebaseClient,
twilio: newTwilioClient(conf, userManager),
twilio: twilioClient,
mailer: sender,
ban: banner,
topics: topics,
@@ -409,13 +413,11 @@ func (s *Server) Run() error {
}()
}
if s.config.MetricsListenHTTP != "" {
initMetrics()
s.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()}
go func() {
errChan <- s.httpMetricsServer.ListenAndServe()
}()
} else if s.config.EnableMetrics {
initMetrics()
s.metricsHandler = promhttp.Handler()
}
if s.config.ProfileListenHTTP != "" {
@@ -506,9 +508,7 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.handleError(w, r, v, err)
return
}
if metricHTTPRequests != nil {
metricHTTPRequests.WithLabelValues("200", "20000", r.Method).Inc()
}
metrics.HTTPRequests.WithLabelValues("200", "20000", r.Method).Inc()
}).
Debug("HTTP request finished")
}
@@ -518,9 +518,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
if !ok {
httpErr = errHTTPInternalError
}
if metricHTTPRequests != nil {
metricHTTPRequests.WithLabelValues(fmt.Sprintf("%d", httpErr.HTTPCode), fmt.Sprintf("%d", httpErr.Code), r.Method).Inc()
}
metrics.HTTPRequests.WithLabelValues(strconv.Itoa(httpErr.HTTPCode), strconv.Itoa(httpErr.Code), r.Method).Inc()
isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode)
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode)
ev := logvr(v, r).Err(err)
@@ -597,6 +595,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountLoginPath {
return s.ensureUser(s.withAccountSync(s.handleAccountLogin))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v)
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {
@@ -820,6 +820,41 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
return writeMatrixDiscoveryResponse(w)
}
// dispatch delivers m to local subscribers and fires the requested side-effect targets. It is
// the single choke point through which every published message must pass; t may be nil when
// the topic has no local subscribers (delayed sender).
func (s *Server) dispatch(v *visitor, t *topic, m *model.Message, opts dispatchOpts) error {
// Deliver to local subscribers
if t != nil {
if opts.async {
go func() {
if err := t.Publish(v, m); err != nil {
logvm(v, m).Err(err).Warn("Unable to publish message")
}
}()
} else if err := t.Publish(v, m); err != nil {
return err
}
}
// Fire the requested side-effect targets
if s.firebaseClient != nil && opts.firebase {
go s.sendToFirebase(v, m)
}
if s.mailer != nil && opts.email != "" {
go s.sendEmail(v, m, opts.email)
}
if s.config.TwilioAccount != "" && opts.call != "" {
go s.callPhone(v, m, opts.call)
}
if s.config.UpstreamBaseURL != "" && opts.upstream {
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" && opts.webPush {
go s.publishToWebPushEndpoints(v, m)
}
return nil
}
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) {
start := time.Now()
t, err := fromContext[*topic](r, contextTopic)
@@ -859,7 +894,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
}
if call != "" {
var httpErr *errHTTP
call, httpErr = s.twilio.convertPhoneNumber(v.User(), call)
call, httpErr = s.convertPhoneNumber(v.User(), call)
if httpErr != nil {
return nil, httpErr.With(t)
} else if !vrate.CallAllowed() {
@@ -898,24 +933,16 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
ev.Debug("Received message")
}
if !delayed {
if err := t.Publish(v, m); err != nil {
err := s.dispatch(v, t, m, dispatchOpts{
firebase: firebase,
email: email,
call: call,
upstream: !unifiedpush, // UP messages are not sent to upstream
webPush: true,
})
if err != nil {
return nil, err
}
if s.firebaseClient != nil && firebase {
go s.sendToFirebase(v, m)
}
if s.mailer != nil && email != "" {
go s.sendEmail(v, m, email)
}
if s.config.TwilioAccount != "" && call != "" {
go s.twilio.callPhone(v, r, m, call)
}
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
} else {
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
}
@@ -944,27 +971,27 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
s.messages++
s.mu.Unlock()
if unifiedpush {
minc(metricUnifiedPushPublishedSuccess)
metrics.UnifiedPushPublishedSuccess.Inc()
}
mset(metricMessagePublishDurationMillis, time.Since(start).Milliseconds())
metrics.MessagePublishDurationMillis.Set(float64(time.Since(start).Milliseconds()))
return m, nil
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
m, err := s.handlePublishInternal(r, v)
if err != nil {
minc(metricMessagesPublishedFailure)
metrics.MessagesPublishedFailure.Inc()
return err
}
minc(metricMessagesPublishedSuccess)
metrics.MessagesPublishedSuccess.Inc()
return s.writeJSON(w, m.ForJSON())
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
_, err := s.handlePublishInternal(r, v)
if err != nil {
minc(metricMessagesPublishedFailure)
minc(metricMatrixPublishedFailure)
metrics.MessagesPublishedFailure.Inc()
metrics.MatrixPublishedFailure.Inc()
if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode {
topic, err := fromContext[*topic](r, contextTopic)
if err != nil {
@@ -980,8 +1007,8 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
}
return err
}
minc(metricMessagesPublishedSuccess)
minc(metricMatrixPublishedSuccess)
metrics.MessagesPublishedSuccess.Inc()
metrics.MatrixPublishedSuccess.Inc()
return writeMatrixSuccess(w)
}
@@ -1014,18 +1041,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
m.Sender = v.IP()
m.User = v.MaybeUserID()
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
// Publish to subscribers
if err := t.Publish(v, m); err != nil {
// Publish to subscribers, Firebase (for Android clients), and web push endpoints
if err := s.dispatch(v, t, m, dispatchOpts{firebase: true, webPush: true}); err != nil {
return err
}
// Send to Firebase for Android clients
if s.firebaseClient != nil {
go s.sendToFirebase(v, m)
}
// Send to web push endpoints
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
if event == model.MessageDeleteEvent {
// Delete any existing scheduled message with the same sequence ID
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
@@ -1053,7 +1072,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
if err := s.firebaseClient.Send(v, m); err != nil {
minc(metricFirebasePublishedFailure)
metrics.FirebasePublishedFailure.Inc()
if errors.Is(err, errFirebaseTemporarilyBanned) {
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
} else {
@@ -1061,17 +1080,17 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
}
return
}
minc(metricFirebasePublishedSuccess)
metrics.FirebasePublishedSuccess.Inc()
}
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil {
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
minc(metricEmailsPublishedFailure)
metrics.EmailsPublishedFailure.Inc()
return
}
minc(metricEmailsPublishedSuccess)
metrics.EmailsPublishedSuccess.Inc()
}
func (s *Server) forwardPollRequest(v *visitor, m *model.Message) {
@@ -1267,7 +1286,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message
} else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
} else if template.Enabled() {
return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5
return s.handleBodyAsTemplatedTextMessage(r.Context(), m, template, body, priorityStr) // Case 5
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 6
}
@@ -1974,24 +1993,18 @@ func (s *Server) sendDelayedMessages() error {
func (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error {
logvm(v, m).Debug("Sending delayed message")
s.mu.RLock()
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
t := s.topics[m.Topic] // May be nil if there are no local subscribers; dispatch handles that
s.mu.RUnlock()
if ok {
go func() {
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
if err := t.Publish(v, m); err != nil {
logvm(v, m).Err(err).Warn("Unable to publish message")
}
}()
}
if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map
go s.sendToFirebase(v, m)
}
if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler.
// Firebase subscribers may not show up in the topics map, so side effects fire regardless.
err := s.dispatch(v, t, m, dispatchOpts{
firebase: true,
upstream: true,
webPush: true,
async: true,
})
if err != nil {
return err
}
if err := s.messageCache.MarkPublished(m); err != nil {
return err
+3 -2
View File
@@ -456,8 +456,9 @@
# doing, and/or secure access to the endpoint in your reverse proxy.
#
# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly
# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
# - metrics-listen-http moves the metrics endpoint to a dedicated [IP]:port, e.g. "10.0.1.1:9090" or ":9090".
# It implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default
# ntfy server does not serve /metrics, even if enable-metrics is also set.
#
# enable-metrics: false
# metrics-listen-http:
+25 -3
View File
@@ -10,6 +10,7 @@ import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/twilio"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -268,6 +269,24 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
return s.writeJSON(w, newSuccessResponse())
}
// handleAccountLogin authenticates a username-or-email + password (via the ensureUser wrapper's
// Basic Auth), mints a session token, and returns it together with the canonical username. Unlike
// the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a
// user in, so it also reports who they are (the identifier they typed may be a primary email).
func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false)
if err != nil {
return err
}
response := &apiAccountLoginResponse{
Token: token.Value,
Username: u.Name,
}
return s.writeJSON(w, response)
}
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
if err != nil {
@@ -613,7 +632,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
}
// Actually add the unverified number, and send verification
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
if err := s.twilio.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
if err := s.twilio.Verify(req.Number, req.Channel); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
@@ -628,7 +647,10 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
}
if err := s.twilio.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
if err := s.twilio.CheckVerify(req.Number, req.Code); err != nil {
if errors.Is(err, twilio.ErrVerificationExpired) {
return errHTTPGonePhoneVerificationExpired
}
return err
}
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
@@ -963,7 +985,7 @@ func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
return err
}
m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))
if err := syncTopic.Publish(v, m); err != nil {
if err := s.dispatch(v, syncTopic, m, dispatchOpts{}); err != nil {
return err
}
return nil
+16
View File
@@ -224,6 +224,22 @@ func canLogin(t *testing.T, s *Server, username, password string) bool {
return rr.Code == 200
}
func TestAccount_LoginByPrimaryEmail(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
// Basic Auth works with either the username or the verified primary email
require.True(t, canLogin(t, s, "ben", "ben"))
require.True(t, canLogin(t, s, "ben@example.com", "ben"))
// ...but not with the wrong password or an unknown email
require.False(t, canLogin(t, s, "ben@example.com", "wrong"))
require.False(t, canLogin(t, s, "nobody@example.com", "ben"))
})
}
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
+52
View File
@@ -55,6 +55,58 @@ func TestAccount_Signup_Success(t *testing.T) {
})
}
func TestAccount_Login_Success(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "phil@example.com"))
// Login by username returns a token and the canonical username
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ := util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
// The returned token actually authenticates
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BearerAuth(resp.Token),
})
require.Equal(t, 200, rr.Code)
// Login by primary email returns the canonical username, not the email that was typed
rr = request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil@example.com", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ = util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
})
}
func TestAccount_Login_InvalidCredentials(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "wrongpass"),
})
require.Equal(t, 401, rr.Code)
})
}
func TestAccount_Signup_UserExists(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
+7 -6
View File
@@ -2,6 +2,7 @@ package server
import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/metrics"
"heckel.io/ntfy/v2/util"
)
@@ -93,13 +94,13 @@ func (s *Server) execManager() {
"emails_sent_failure": sentMailFailure,
}).
Info("Server stats")
mset(metricMessagesCached, messagesCached)
mset(metricVisitors, visitorsCount)
mset(metricUsers, usersCount)
mset(metricSubscribers, subscribers)
mset(metricTopics, topicsCount)
metrics.MessagesCached.Set(float64(messagesCached))
metrics.Visitors.Set(float64(visitorsCount))
metrics.Users.Set(float64(usersCount))
metrics.Subscribers.Set(float64(subscribers))
metrics.Topics.Set(float64(topicsCount))
if s.attachment != nil {
mset(metricAttachmentsTotalSize, s.attachment.Size())
metrics.AttachmentsTotalSize.Set(float64(s.attachment.Size()))
}
}
-132
View File
@@ -1,132 +0,0 @@
package server
import (
"github.com/prometheus/client_golang/prometheus"
)
var (
metricMessagesPublishedSuccess prometheus.Counter
metricMessagesPublishedFailure prometheus.Counter
metricMessagesCached prometheus.Gauge
metricMessagePublishDurationMillis prometheus.Gauge
metricFirebasePublishedSuccess prometheus.Counter
metricFirebasePublishedFailure prometheus.Counter
metricEmailsPublishedSuccess prometheus.Counter
metricEmailsPublishedFailure prometheus.Counter
metricEmailsReceivedSuccess prometheus.Counter
metricEmailsReceivedFailure prometheus.Counter
metricCallsMadeSuccess prometheus.Counter
metricCallsMadeFailure prometheus.Counter
metricUnifiedPushPublishedSuccess prometheus.Counter
metricMatrixPublishedSuccess prometheus.Counter
metricMatrixPublishedFailure prometheus.Counter
metricAttachmentsTotalSize prometheus.Gauge
metricVisitors prometheus.Gauge
metricSubscribers prometheus.Gauge
metricTopics prometheus.Gauge
metricUsers prometheus.Gauge
metricHTTPRequests *prometheus.CounterVec
)
func initMetrics() {
metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_success",
})
metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_failure",
})
metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_messages_cached_total",
})
metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_message_publish_duration_ms",
})
metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_success",
})
metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_failure",
})
metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_success",
})
metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_failure",
})
metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_success",
})
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_failure",
})
metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_success",
})
metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_failure",
})
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_unifiedpush_published_success",
})
metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_success",
})
metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_failure",
})
metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_attachments_total_size",
})
metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_visitors_total",
})
metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_users_total",
})
metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_subscribers_total",
})
metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_topics_total",
})
metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_http_requests_total",
}, []string{"http_code", "ntfy_code", "http_method"})
prometheus.MustRegister(
metricMessagesPublishedSuccess,
metricMessagesPublishedFailure,
metricMessagesCached,
metricMessagePublishDurationMillis,
metricFirebasePublishedSuccess,
metricFirebasePublishedFailure,
metricEmailsPublishedSuccess,
metricEmailsPublishedFailure,
metricEmailsReceivedSuccess,
metricEmailsReceivedFailure,
metricCallsMadeSuccess,
metricCallsMadeFailure,
metricUnifiedPushPublishedSuccess,
metricMatrixPublishedSuccess,
metricMatrixPublishedFailure,
metricAttachmentsTotalSize,
metricVisitors,
metricUsers,
metricSubscribers,
metricTopics,
metricHTTPRequests,
)
}
// minc increments a prometheus.Counter if it is non-nil
func minc(counter prometheus.Counter) {
if counter != nil {
counter.Inc()
}
}
// mset sets a prometheus.Gauge if it is non-nil
func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) {
if gauge != nil {
gauge.Set(float64(value))
}
}
+70 -16
View File
@@ -2,10 +2,14 @@ package server
import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"text/template/parse"
"time"
@@ -17,7 +21,33 @@ import (
"heckel.io/ntfy/v2/util/sprig"
)
func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
var (
//go:embed templates
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
templatesDir = "templates"
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
// templatePrintfLargeSizeRegex matches a printf directive whose width or precision is a star
// (taken from an argument) or has four or more digits, i.e. is at least 1000. It deliberately
// scans the flag/width/precision characters after a % without requiring a well-formed
// directive: fmt pads even malformed ones (e.g. "%000 9999999#" emits 10 MB), so anything
// unrecognized must still be caught.
templatePrintfLargeSizeRegex = regexp.MustCompile(`%[-+# 0-9.*\[\]]*(\*|[0-9]{4})`)
// templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard
// (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never
// mutated in production.
templateMaxExecutionTime = 100 * time.Millisecond
)
const (
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateMaxTemplateBytes = 32 * 1024 // Maximum size of a template (inline or from a template file), used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
)
func (s *Server) handleBodyAsTemplatedTextMessage(ctx context.Context, m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil {
return err
@@ -26,11 +56,11 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem
}
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
if template.FileMode() {
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
if err := s.renderTemplateFromFile(ctx, m, template.FileName(), peekedBody); err != nil {
return err
}
} else {
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
if err := s.renderTemplateFromParams(ctx, m, peekedBody, priorityStr); err != nil {
return err
}
}
@@ -42,7 +72,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
// The template file must be in the templates directory, or in the configured template directory.
func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error {
func (s *Server) renderTemplateFromFile(ctx context.Context, m *model.Message, templateName, peekedBody string) error {
if !templateNameRegex.MatchString(templateName) {
return errHTTPBadRequestTemplateFileNotFound
}
@@ -61,17 +91,17 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo
}
var err error
if tpl.Message != nil {
if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil {
if m.Message, err = s.renderTemplate(ctx, templateName+" (message)", *tpl.Message, peekedBody); err != nil {
return err
}
}
if tpl.Title != nil {
if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil {
if m.Title, err = s.renderTemplate(ctx, templateName+" (title)", *tpl.Title, peekedBody); err != nil {
return err
}
}
if tpl.Priority != nil {
renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody)
renderedPriority, err := s.renderTemplate(ctx, templateName+" (priority)", *tpl.Priority, peekedBody)
if err != nil {
return err
}
@@ -84,16 +114,16 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
// message, title, and priority parameters.
func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error {
func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message, peekedBody string, priorityStr string) error {
var err error
if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil {
if m.Message, err = s.renderTemplate(ctx, "priority query parameter", m.Message, peekedBody); err != nil {
return err
}
if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil {
if m.Title, err = s.renderTemplate(ctx, "title query parameter", m.Title, peekedBody); err != nil {
return err
}
if priorityStr != "" {
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody)
renderedPriority, err := s.renderTemplate(ctx, "priority query parameter", priorityStr, peekedBody)
if err != nil {
return err
}
@@ -105,23 +135,30 @@ func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, p
}
// renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
if len(tpl) > templateMaxTemplateBytes {
return "", errHTTPBadRequestTemplateTooLarge
}
var data any
if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON
}
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Funcs(gotext.FuncMap{"printf": templatePrintf}).Parse(tpl)
if err != nil {
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
}
if templateUsesDisallowedFeatures(t) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
}
t.SetExecutionDeadline(time.Now().Add(templateMaxExecutionTime)) // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp)
// Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp). The deadline starts here, after the body
// has already been read, so a slow upload is not counted against it. Deriving from the request
// context means a client disconnect aborts the render too.
execCtx, cancel := context.WithTimeout(ctx, templateMaxExecutionTime)
defer cancel()
var buf bytes.Buffer
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
if err := t.Execute(limitWriter, data); err != nil {
if errors.Is(err, gotext.ErrExecutionInterrupted) {
if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", errHTTPBadRequestTemplateExecutionTimeout
}
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
@@ -164,6 +201,8 @@ func treeContainsDisallowedNode(node parse.Node) bool {
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
case *parse.TemplateNode: // {{template}} or {{block}} invocation
return true
case *parse.ChainNode: // A term followed by field accesses, e.g. (call .x).y
return treeContainsDisallowedNode(n.Node)
case *parse.PipeNode:
if n == nil {
return false
@@ -184,3 +223,18 @@ func treeContainsDisallowedNode(node parse.Node) bool {
}
return false
}
// templatePrintf is the template builtin printf, guarded against memory amplification: fmt
// allows widths and precisions up to 1e6 per verb, so a small template like
// {{printf "%999999d%999999d..." ...}} can allocate gigabytes inside a single fmt call -- and the
// executor's cancellation context is only checked between template nodes, never inside one.
// Widths and precisions of 1000 or more are therefore rejected, as is the star (*) form, which
// takes the width from an argument. Combined with the template size limit, this bounds a single
// render to a few MB. Registered via Funcs, which takes precedence over the builtin, and checked
// at call time so a format string assembled during execution is covered too.
func templatePrintf(format string, args ...any) (string, error) {
if templatePrintfLargeSizeRegex.MatchString(strings.ReplaceAll(format, "%%", "")) { // Strip escaped percent signs, they take no width
return "", errors.New("printf width or precision too large")
}
return fmt.Sprintf(format, args...), nil
}
+131
View File
@@ -0,0 +1,131 @@
package server
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestServer_MessageTemplate_TooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar"}`, map[string]string{
"X-Message": "{{.foo}}" + strings.Repeat("x", 33*1024),
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40056, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_MessageTemplate_PrintfWidthTooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// A handful of 1MB-wide verbs would allocate several MB inside a single fmt call, where
// the executor's context is never checked; the printf guard must reject the call before
// fmt runs, not after the limit writer sees the output
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{printf "%1000000d%1000000d%1000000d" .n .n .n}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfWidthTooLarge_DynamicFormat(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// The format string is assembled at execution time, so the guard must inspect the actual
// argument, not the template source
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{$f := print "%" "999999" "d" "%" "999999" "d"}}{{printf $f .n .n}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfStarWidthTooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// Star widths take the width from an argument; sprig's math functions (int64 results)
// make large integer arguments reachable from a template
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{printf "%*d" (mul 1000 2000) 1}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfSmallWidthStillWorks(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", `{"n":7}`, map[string]string{
"X-Message": `{{printf "%05d" 7}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
require.Equal(t, "00007", toMessage(t, response.Body.String()).Message)
})
}
func Test_templatePrintf(t *testing.T) {
tests := []struct {
format string
args []any
want string // Empty means the call must be rejected
}{
{"%d", []any{5}, "5"},
{"%05d", []any{5}, "00005"},
{"%-8.3f|", []any{1.5}, "1.500 |"},
{"%1000d", []any{1}, ""}, // Rejected: four digits
{"%.1000s", []any{"x"}, ""},
{"%*d", []any{500, 1}, ""}, // Rejected: star width
{"%.*s", []any{400, "x"}, ""}, // Rejected: star precision
{"%[1]1000000d", []any{1}, ""}, // Rejected: explicit arg index does not hide the width
{"%[2]*[1]d", []any{6, 12}, ""}, // Rejected: star width behind an arg index
{"100%% of 2024 values", nil, "100% of 2024 values"}, // Literal digits are not a width
}
for _, test := range tests {
out, err := templatePrintf(test.format, test.args...)
if test.want == "" {
require.Error(t, err, "format %q must be rejected", test.format)
require.Contains(t, err.Error(), "too large")
} else {
require.Nil(t, err, "format %q", test.format)
require.Equal(t, test.want, out)
}
}
// The largest allowed width still produces bounded output
out, err := templatePrintf("%999d", 1)
require.Nil(t, err)
require.Len(t, out, 999)
}
func TestServer_MessageTemplate_DisallowedCallInChain(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// {{call}} behind a field access parses into a ChainNode. JSON data cannot produce a
// function value, so this cannot be exploited today, but the ban must catch every
// syntactic form rather than relying on the call failing at runtime.
response := request(t, s, "PUT", "/mytopic", `{"fn":1}`, map[string]string{
"X-Message": `{{(call .fn).x}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code)
})
}
+74 -3
View File
@@ -22,6 +22,7 @@ import (
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
dbtest "heckel.io/ntfy/v2/db/test"
@@ -323,6 +324,40 @@ func TestServer_WebEnabled(t *testing.T) {
require.Equal(t, 200, rr.Code)
})
}
// TestServer_MetricsEnabled ensures that the /metrics endpoint serves the registered ntfy metrics
// once the metrics handler is set (as Serve does when enable-metrics is configured).
func TestServer_MetricsEnabled(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.metricsHandler = promhttp.Handler() // Serve sets this when enable-metrics is configured
// Count at least one request first: Prometheus only reports a CounterVec such as
// ntfy_http_requests_total once it has children
request(t, s, "GET", "/v1/health", "", nil)
rr := request(t, s, "GET", "/metrics", "", nil)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), "ntfy_messages_published_success")
require.Contains(t, rr.Body.String(), "ntfy_http_requests_total")
})
}
// TestServer_MetricsDisabled ensures that the ntfy metrics are not exposed when the metrics handler
// is unset (the default). The collectors are always registered with the Prometheus registry, so a
// nil metrics handler is the only thing keeping them off the wire.
func TestServer_MetricsDisabled(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfig(t, databaseURL)
conf.WebRoot = "" // Disable the web app, so its catch-all does not mask the /metrics route
s := newTestServer(t, conf)
rr := request(t, s, "GET", "/metrics", "", nil)
require.Equal(t, 404, rr.Code)
require.NotContains(t, rr.Body.String(), "ntfy_messages_published_success")
})
}
func TestServer_PublishLargeMessage(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfig(t, databaseURL)
@@ -1684,6 +1719,7 @@ func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) {
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
require.Equal(t, "zzz@example.com", mailer.LastTo())
})
}
@@ -1710,6 +1746,7 @@ func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) {
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
require.Equal(t, "zzz@example.com", mailer.LastTo())
})
}
@@ -1751,6 +1788,7 @@ func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T)
"Authorization": util.BasicAuth("prov", "provpass"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
require.Equal(t, "zzz@example.com", mailer.LastTo())
})
}
@@ -3764,9 +3802,8 @@ func (b *slowBody) Close() error { return nil }
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
start := time.Now()
// The loop makes the template execute enough nodes (>256) to actually hit the deadline check,
// so this test distinguishes correct behavior from a deadline that includes upload time -- yet
// it runs in ~1ms, far under the deadline, so on correct code it renders fine.
// The template runs in ~1ms, far under the deadline, so on correct code it renders fine; the
// point is that the deadline starts at execution, not when the (slow) upload began.
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
"Template": "yes",
"X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`,
@@ -3780,6 +3817,40 @@ func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.
require.Equal(t, "hello bar", m.Message)
}
// TestServer_MessageTemplate_ClientDisconnect_CancelsRender verifies that canceling the request
// context (e.g. the client disconnecting) aborts an in-progress template render. The execution
// deadline is raised well above the cancel delay for this test so that cancellation -- not the
// deadline -- is what stops the render: a runaway template is canceled 500ms in and must abort
// shortly after (well under the raised deadline), yielding the generic execute-failed code (40045),
// not the timeout code (40055).
//
// Not parallel: it temporarily raises the package-global templateMaxExecutionTime. Non-parallel
// tests run in their own phase (parallel tests are paused), so the override is race-free.
func TestServer_MessageTemplate_ClientDisconnect_CancelsRender(t *testing.T) {
origDeadline := templateMaxExecutionTime
templateMaxExecutionTime = 30 * time.Second // large enough that only the cancel can stop the render
defer func() { templateMaxExecutionTime = origDeadline }()
s := newTestServer(t, newTestConfig(t, ""))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(500 * time.Millisecond)
cancel()
}()
start := time.Now()
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
"X-Template": "1",
}, func(r *http.Request) {
*r = *r.WithContext(ctx)
})
elapsed := time.Since(start)
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code, "a canceled render should map to execute-failed, not the timeout code 40055")
require.Greater(t, elapsed, 500*time.Millisecond, "render must still be running when the cancel fires (took %s)", elapsed)
require.Less(t, elapsed, 700*time.Millisecond, "request-context cancel should abort the render promptly after firing (took %s)", elapsed)
}
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
+17 -188
View File
@@ -1,77 +1,21 @@
package server
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"text/template"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/metrics"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/twilio"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
// twilioClient talks to the Twilio API to make phone calls (for the "Call" feature) and to verify
// phone numbers. It holds the Twilio configuration and the user manager (used to look up a user's
// verified phone numbers), so that this functionality is decoupled from the main Server.
type twilioClient struct {
config *Config
userManager *user.Manager // May be nil!
}
func newTwilioClient(conf *Config, userManager *user.Manager) *twilioClient {
return &twilioClient{
config: conf,
userManager: userManager,
}
}
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
// It can be overridden in the server configuration's twilio-call-format field.
//
// The format uses Go template syntax with the following fields:
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
// String fields are automatically XML-escaped.
var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(`
<Response>
<Pause length="1"/>
<Say loop="3">
You have a message from notify on topic {{.Topic}}. Message:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
End of message.
<break time="1s"/>
This message was sent by user {{.Sender}}. It will be repeated three times.
To unsubscribe from calls like this, remove your phone number in the notify web app.
<break time="3s"/>
</Say>
<Say>Goodbye.</Say>
</Response>`))
// twilioCallData holds the data passed to the Twilio call format template
type twilioCallData struct {
Topic string
Title string
Message string
Priority int
Tags []string
Sender string
}
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
// If the user is anonymous, it will return an error.
func (c *twilioClient) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
if u == nil {
return "", errHTTPBadRequestAnonymousCallsNotAllowed
}
phoneNumbers, err := c.userManager.PhoneNumbers(u.ID)
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if len(phoneNumbers) == 0 {
@@ -87,139 +31,24 @@ func (c *twilioClient) convertPhoneNumber(u *user.User, phoneNumber string) (str
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
// Failures will be logged, but not returned to the caller.
func (c *twilioClient) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
func (s *Server) callPhone(v *visitor, m *model.Message, to string) {
u, sender := v.User(), m.Sender.String()
if u != nil {
sender = u.Name
}
tmpl := defaultTwilioCallFormatTemplate
if c.config.TwilioCallFormat != nil {
tmpl = c.config.TwilioCallFormat
}
tags := make([]string, len(m.Tags))
for i, tag := range m.Tags {
tags[i] = xmlEscapeText(tag)
}
templateData := &twilioCallData{
Topic: xmlEscapeText(m.Topic),
Title: xmlEscapeText(m.Title),
Message: xmlEscapeText(m.Message),
logvm(v, m).Tag(tagTwilio).Field("twilio_to", to).Info("Making phone call to %s", to)
err := s.twilio.Call(to, &twilio.CallData{
Topic: m.Topic,
Title: m.Title,
Message: m.Message,
Priority: m.Priority,
Tags: tags,
Sender: xmlEscapeText(sender),
}
var bodyBuf bytes.Buffer
if err := tmpl.Execute(&bodyBuf, templateData); err != nil {
logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
minc(metricCallsMadeFailure)
Tags: m.Tags,
Sender: sender,
})
if err != nil {
logvm(v, m).Tag(tagTwilio).Field("twilio_to", to).Err(err).Warn("Unable to call phone %s: %v", to, err.Error())
metrics.CallsMadeFailure.Inc()
return
}
body := bodyBuf.String()
data := url.Values{}
data.Set("From", c.config.TwilioPhoneNumber)
data.Set("To", to)
data.Set("Twiml", body)
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
response, err := c.callPhoneInternal(data)
if err != nil {
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
minc(metricCallsMadeFailure)
return
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
minc(metricCallsMadeSuccess)
}
func (c *twilioClient) callPhoneInternal(data url.Values) (string, error) {
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.TwilioCallsBaseURL, c.config.TwilioAccount)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(response), nil
}
func (c *twilioClient) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Channel", channel)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.TwilioVerifyBaseURL, c.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
response, err := io.ReadAll(resp.Body)
if err != nil {
ev.Err(err).Warn("Error sending Twilio phone verification request")
return err
}
ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
return nil
}
func (c *twilioClient) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.TwilioVerifyBaseURL, c.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
} else if resp.StatusCode != http.StatusOK {
if ev.IsTrace() {
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ev.Field("twilio_response", string(response))
}
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
if resp.StatusCode == http.StatusNotFound {
return errHTTPGonePhoneVerificationExpired
}
return errHTTPInternalError
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if ev.IsTrace() {
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
} else if ev.IsDebug() {
ev.Debug("Received successful Twilio phone verification response")
}
return nil
}
func xmlEscapeText(text string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(text))
return buf.String()
metrics.CallsMadeSuccess.Inc()
}
+3 -2
View File
@@ -19,6 +19,7 @@ import (
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
"heckel.io/ntfy/v2/metrics"
"heckel.io/ntfy/v2/model"
)
@@ -180,7 +181,7 @@ func (s *smtpSession) Data(r io.Reader) error {
s.backend.mu.Lock()
s.backend.success++
s.backend.mu.Unlock()
minc(metricEmailsReceivedSuccess)
metrics.EmailsReceivedSuccess.Inc()
return nil
})
}
@@ -238,7 +239,7 @@ func (s *smtpSession) withFailCount(fn func() error) error {
// We do not want to spam the log with WARN messages.
logem(s.conn).Err(err).Debug("Incoming mail error")
s.backend.failure++
minc(metricEmailsReceivedFailure)
metrics.EmailsReceivedFailure.Inc()
}
return err
}
+19
View File
@@ -29,6 +29,17 @@ type publishMessage struct {
Delay string `json:"delay"`
}
// dispatchOpts selects which delivery targets fire for a published message, beyond delivery
// to local subscribers (see Server.dispatch)
type dispatchOpts struct {
firebase bool // Send to Firebase (if configured)
email string // Send an email to this address (if a mailer is configured)
call string // Call this phone number (if Twilio is configured)
upstream bool // Forward a poll request to the upstream server (if configured)
webPush bool // Publish to web push endpoints (if configured)
async bool // Deliver to local subscribers in a goroutine, logging errors instead of returning them
}
// messageEncoder is a function that knows how to encode a message
type messageEncoder func(msg *model.Message) (string, error)
@@ -217,6 +228,14 @@ type apiAccountTokenResponse struct {
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
}
// apiAccountLoginResponse is the body of POST /v1/account/login: it authenticates a
// username-or-email + password, mints a session token, and returns the token together with the
// canonical username (which may differ from the identifier the user typed, e.g. a primary email).
type apiAccountLoginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
}
type apiAccountPhoneNumberVerifyRequest struct {
Number string `json:"number"`
Channel string `json:"channel"`
+30 -18
View File
@@ -1,8 +1,8 @@
# `template/gotext/` -- vendored `text/template` with an execution deadline
# `template/gotext/` -- vendored `text/template` with context cancellation
This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one
small patch that adds a wall-clock execution deadline. It exists for exactly one reason: to stop
**user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
small patch that adds context-aware execution (`ExecuteContext`). It exists for exactly one reason:
to stop **user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
from burning CPU.
- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src`
@@ -14,7 +14,8 @@ from burning CPU.
ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template`
**cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation
([golang/go#31107](https://github.com/golang/go/issues/31107) was declined). So a crafted template
([golang/go#31107](https://github.com/golang/go/issues/31107) proposed `ExecuteContext` but was
declined, over a bundled context-*values* feature, not cancellation itself). So a crafted template
with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that
writes no output) can run for tens of seconds on a single request. That is a CPU denial of service
(GHSA-rhwf-xgc9-m9fp).
@@ -22,13 +23,17 @@ writes no output) can run for tens of seconds on a single request. That is a CPU
There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is
unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile
heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add
a **single check inside `walk`**: every ~256 nodes it checks a wall-clock deadline and aborts (via
the normal `ExecError` path) if it has passed. This bounds CPU for *any* template shape -- cheap
loops and expensive functions alike -- by construction.
the cancellation half of #31107 as a patch: `ExecuteContext(ctx, ...)` that aborts with `ctx.Err()`
when `ctx` is canceled or its deadline passes. The check is a **single poll inside `walk`** of an
atomic flag that a `context.AfterFunc` watcher flips -- so it bounds CPU for *any* template shape
(cheap loops and expensive functions alike), it is exact (observed within one node), and it adds no
measurable overhead. If #31107's cancellation half ever lands upstream, this fork can be deleted and
the call site keeps compiling unchanged.
The one user-facing execution site (`server/server_template.go` `renderTemplate`) sets the deadline
with `SetExecutionDeadline` and maps the resulting error to a `400`. Trusted templates (operator
config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not user-supplied.
The one user-facing execution site (`server/server_template.go` `renderTemplate`) wraps execution in
`context.WithTimeout` and calls `ExecuteContext`, mapping `context.DeadlineExceeded` to a `400`.
Trusted templates (operator config: Twilio, `cmd/serve.go`) keep using the standard library -- they
are not user-supplied.
## What's here
@@ -36,7 +41,7 @@ config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not
|------|--------|
| `*.go` (`exec.go`, `funcs.go`, `template.go`, `option.go`, `helper.go`, `doc.go`) | verbatim from `$(go env GOROOT)/src/text/template/`, enumerated with `go list` so files added/removed upstream are picked up automatically |
| `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along |
| `patches/0001-exec-deadline.patch` | our only real change (see below) |
| `patches/0001-exec-context.patch` | our only real change (see below) |
| `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target |
The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version)
@@ -49,19 +54,26 @@ plain import.
## The patch
`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just
`0001-exec-deadline.patch` -- small, purely additive, and touching only `exec.go`/`template.go`:
`0001-exec-context.patch` -- small, purely additive, and touching only `exec.go`:
- adds `deadline`/`steps` fields to the executor `state` and a `deadline` field + a
`SetExecutionDeadline(time.Time)` method on `Template`
- adds the amortized deadline check at the top of `state.walk`
- adds the exported sentinel `ErrExecutionInterrupted` (detect with `errors.Is`)
- adds `ctx context.Context` and a shared `cancelled *atomic.Bool` to the executor `state`
- adds `ExecuteContext` / `ExecuteTemplateContext`; `Execute` / `ExecuteTemplate` become
`context.Background()` wrappers, so their behavior and cost are unchanged
- when `ctx.Done() != nil`, arms one `context.AfterFunc` watcher that flips the flag; `walk` polls it
per node and aborts via a `cancelError` that `errRecover` strips to the bare `ctx.Err()`
(`errors.Is(err, context.DeadlineExceeded)`)
The flag is a `*atomic.Bool` (not a value) because `walkTemplate` copies `state` for nested
`{{template}}` invocations; a shared pointer keeps one flag across all copies and avoids `go vet`
copylocks. `template.go` is unchanged -- the context is per-call, not stored on the `Template`.
Two *mechanical* transforms are applied by `make update-template` with `sed`, **not** the patch --
renaming the package to `gotext`, and rewriting the `internal/fmtsort` import to
`heckel.io/ntfy/v2/template/gotext/fmtsort`. Keeping them out of the patch means they apply to
whatever files `go list` returns, so they survive upstream files being added or removed.
whatever files `go list` returns, so they survive upstream files being added or removed. (These two
transforms are also the only difference between our patch and the upstream `text/template` diff.)
Keeping the patch tiny (deadline logic only, on two stable files) is deliberate: it makes re-basing
Keeping the patch tiny (cancellation only, on one stable file) is deliberate: it makes re-basing
onto a new Go release cheap.
## Updating (when bumping the Go toolchain)
+66 -23
View File
@@ -5,14 +5,15 @@
package gotext
import (
"context"
"errors"
"fmt"
"io"
"reflect"
"runtime"
"strings"
"sync/atomic"
"text/template/parse"
"time"
"heckel.io/ntfy/v2/template/gotext/fmtsort"
)
@@ -34,13 +35,13 @@ func initMaxExecDepth() int {
// template so that multiple executions of the same template
// can execute in parallel.
type state struct {
tmpl *Template
wr io.Writer
node parse.Node // current node, for errors
vars []variable // push-down stack of variable values.
depth int // the height of the stack of executing templates.
deadline time.Time // ntfy: wall-clock bail-out; zero means no limit
steps int64 // ntfy: node counter for amortized deadline checks
tmpl *Template
ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
wr io.Writer
node parse.Node // current node, for errors
vars []variable // push-down stack of variable values.
depth int // the height of the stack of executing templates.
cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
}
// variable holds the dynamic value of a variable such as $, $x etc.
@@ -135,10 +136,6 @@ func (e ExecError) Unwrap() error {
return e.Err
}
// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the
// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition)
var ErrExecutionInterrupted = errors.New("template execution interrupted")
// errorf records an ExecError and terminates processing.
func (s *state) errorf(format string, args ...any) {
name := doublePercent(s.tmpl.Name())
@@ -168,6 +165,14 @@ func (s *state) writeError(err error) {
})
}
// cancelError is the wrapper type used internally when execution is aborted
// because the context is done. Like writeError, it is stripped in errRecover
// so the caller receives the original ctx.Err(). It is not an implementation
// of error, so it cannot escape from the package as an error value.
type cancelError struct {
Err error // Original context error.
}
// errRecover is the handler that turns panics into returns from the top
// level of Parse.
func errRecover(errp *error) {
@@ -178,6 +183,8 @@ func errRecover(errp *error) {
panic(e)
case writeError:
*errp = err.Err // Strip the wrapper.
case cancelError:
*errp = err.Err // Strip the wrapper; return the context error.
case ExecError:
*errp = err // Keep the wrapper.
default:
@@ -194,11 +201,19 @@ func errRecover(errp *error) {
// A template may be executed safely in parallel, although if parallel
// executions share a Writer the output may be interleaved.
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
return t.ExecuteTemplateContext(context.Background(), wr, name, data)
}
// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
// execution completes. See [Template.ExecuteContext] for the cancellation
// semantics.
func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
tmpl := t.Lookup(name)
if tmpl == nil {
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
}
return tmpl.Execute(wr, data)
return tmpl.ExecuteContext(ctx, wr, data)
}
// Execute applies a parsed template to the specified data object,
@@ -212,20 +227,47 @@ func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
// If data is a [reflect.Value], the template applies to the concrete
// value that the reflect.Value holds, as in [fmt.Print].
func (t *Template) Execute(wr io.Writer, data any) error {
return t.execute(wr, data)
return t.executeContext(context.Background(), wr, data)
}
func (t *Template) execute(wr io.Writer, data any) (err error) {
// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
// completes.
//
// Cancellation is observed between node evaluations as the template is walked,
// so long-running renders -- including tight or nested {{range}} loops that
// write no output -- are aborted promptly. A template blocked inside a single
// function call is not interrupted until that call returns. Partial results may
// already have been written to wr.
func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
if err := ctx.Err(); err != nil {
return err
}
return t.executeContext(ctx, wr, data)
}
func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
defer errRecover(&err)
value, ok := data.(reflect.Value)
if !ok {
value = reflect.ValueOf(data)
}
state := &state{
tmpl: t,
wr: wr,
vars: []variable{{"$", value}},
deadline: t.deadline, // ntfy: wall-clock execution bail-out
tmpl: t,
ctx: ctx,
wr: wr,
vars: []variable{{"$", value}},
}
// If the context can be canceled, watch it with a single context.AfterFunc
// callback that flips an atomic flag; walk polls that flag per node (a cheap
// monomorphic atomic load) instead of calling ctx.Err() every node.
// Contexts that can never be canceled (Background, TODO) have a nil Done
// channel, so the default Execute path installs nothing and pays nothing.
if ctx.Done() != nil {
state.cancelled = new(atomic.Bool)
stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
defer stop()
}
if t.Tree == nil || t.Root == nil {
state.errorf("%q is an incomplete or empty template", t.Name())
@@ -269,10 +311,11 @@ var (
// generating output as they go.
func (s *state) walk(dot reflect.Value, node parse.Node) {
s.at(node)
// ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates
// (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp.
if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) {
s.errorf("execution interrupted: %w", ErrExecutionInterrupted)
// Abort if the context has been canceled or its deadline has passed. The
// flag is set by the watcher installed in executeContext; observing it here
// interrupts any template shape, including loops that write no output.
if s.cancelled != nil && s.cancelled.Load() {
panic(cancelError{s.ctx.Err()})
}
switch node := node.(type) {
case *parse.ActionNode:
@@ -0,0 +1,149 @@
--- a/exec.go 2026-07-10 01:31:35.188129862 +0200
+++ b/exec.go 2026-07-10 01:31:35.189129894 +0200
@@ -5,14 +5,17 @@
package gotext
import (
+ "context"
"errors"
"fmt"
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
"io"
"reflect"
"runtime"
"strings"
+ "sync/atomic"
"text/template/parse"
+
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
)
// maxExecDepth specifies the maximum stack depth of templates within
@@ -32,11 +35,13 @@
// template so that multiple executions of the same template
// can execute in parallel.
type state struct {
- tmpl *Template
- wr io.Writer
- node parse.Node // current node, for errors
- vars []variable // push-down stack of variable values.
- depth int // the height of the stack of executing templates.
+ tmpl *Template
+ ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
+ wr io.Writer
+ node parse.Node // current node, for errors
+ vars []variable // push-down stack of variable values.
+ depth int // the height of the stack of executing templates.
+ cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
}
// variable holds the dynamic value of a variable such as $, $x etc.
@@ -160,6 +165,14 @@
})
}
+// cancelError is the wrapper type used internally when execution is aborted
+// because the context is done. Like writeError, it is stripped in errRecover
+// so the caller receives the original ctx.Err(). It is not an implementation
+// of error, so it cannot escape from the package as an error value.
+type cancelError struct {
+ Err error // Original context error.
+}
+
// errRecover is the handler that turns panics into returns from the top
// level of Parse.
func errRecover(errp *error) {
@@ -170,6 +183,8 @@
panic(e)
case writeError:
*errp = err.Err // Strip the wrapper.
+ case cancelError:
+ *errp = err.Err // Strip the wrapper; return the context error.
case ExecError:
*errp = err // Keep the wrapper.
default:
@@ -186,11 +201,19 @@
// A template may be executed safely in parallel, although if parallel
// executions share a Writer the output may be interleaved.
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
+ return t.ExecuteTemplateContext(context.Background(), wr, name, data)
+}
+
+// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
+// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
+// execution completes. See [Template.ExecuteContext] for the cancellation
+// semantics.
+func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
tmpl := t.Lookup(name)
if tmpl == nil {
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
}
- return tmpl.Execute(wr, data)
+ return tmpl.ExecuteContext(ctx, wr, data)
}
// Execute applies a parsed template to the specified data object,
@@ -204,10 +227,27 @@
// If data is a [reflect.Value], the template applies to the concrete
// value that the reflect.Value holds, as in [fmt.Print].
func (t *Template) Execute(wr io.Writer, data any) error {
- return t.execute(wr, data)
+ return t.executeContext(context.Background(), wr, data)
}
-func (t *Template) execute(wr io.Writer, data any) (err error) {
+// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
+// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
+// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
+// completes.
+//
+// Cancellation is observed between node evaluations as the template is walked,
+// so long-running renders -- including tight or nested {{range}} loops that
+// write no output -- are aborted promptly. A template blocked inside a single
+// function call is not interrupted until that call returns. Partial results may
+// already have been written to wr.
+func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return t.executeContext(ctx, wr, data)
+}
+
+func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
defer errRecover(&err)
value, ok := data.(reflect.Value)
if !ok {
@@ -215,9 +255,20 @@
}
state := &state{
tmpl: t,
+ ctx: ctx,
wr: wr,
vars: []variable{{"$", value}},
}
+ // If the context can be canceled, watch it with a single context.AfterFunc
+ // callback that flips an atomic flag; walk polls that flag per node (a cheap
+ // monomorphic atomic load) instead of calling ctx.Err() every node.
+ // Contexts that can never be canceled (Background, TODO) have a nil Done
+ // channel, so the default Execute path installs nothing and pays nothing.
+ if ctx.Done() != nil {
+ state.cancelled = new(atomic.Bool)
+ stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
+ defer stop()
+ }
if t.Tree == nil || t.Root == nil {
state.errorf("%q is an incomplete or empty template", t.Name())
}
@@ -260,6 +311,12 @@
// generating output as they go.
func (s *state) walk(dot reflect.Value, node parse.Node) {
s.at(node)
+ // Abort if the context has been canceled or its deadline has passed. The
+ // flag is set by the watcher installed in executeContext; observing it here
+ // interrupts any template shape, including loops that write no output.
+ if s.cancelled != nil && s.cancelled.Load() {
+ panic(cancelError{s.ctx.Err()})
+ }
switch node := node.(type) {
case *parse.ActionNode:
// Do not pop variables so they persist until next end.
@@ -1,113 +0,0 @@
diff -ruN a/exec.go b/exec.go
--- a/exec.go 2026-07-08 21:46:30.952555712 +0200
+++ b/exec.go 2026-07-08 21:46:30.953912265 +0200
@@ -7,12 +7,14 @@
import (
"errors"
"fmt"
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
"io"
"reflect"
"runtime"
"strings"
"text/template/parse"
+ "time"
+
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
)
// maxExecDepth specifies the maximum stack depth of templates within
@@ -32,11 +34,13 @@
// template so that multiple executions of the same template
// can execute in parallel.
type state struct {
- tmpl *Template
- wr io.Writer
- node parse.Node // current node, for errors
- vars []variable // push-down stack of variable values.
- depth int // the height of the stack of executing templates.
+ tmpl *Template
+ wr io.Writer
+ node parse.Node // current node, for errors
+ vars []variable // push-down stack of variable values.
+ depth int // the height of the stack of executing templates.
+ deadline time.Time // ntfy: wall-clock bail-out; zero means no limit
+ steps int64 // ntfy: node counter for amortized deadline checks
}
// variable holds the dynamic value of a variable such as $, $x etc.
@@ -131,6 +135,10 @@
return e.Err
}
+// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the
+// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition)
+var ErrExecutionInterrupted = errors.New("template execution interrupted")
+
// errorf records an ExecError and terminates processing.
func (s *state) errorf(format string, args ...any) {
name := doublePercent(s.tmpl.Name())
@@ -214,9 +222,10 @@
value = reflect.ValueOf(data)
}
state := &state{
- tmpl: t,
- wr: wr,
- vars: []variable{{"$", value}},
+ tmpl: t,
+ wr: wr,
+ vars: []variable{{"$", value}},
+ deadline: t.deadline, // ntfy: wall-clock execution bail-out
}
if t.Tree == nil || t.Root == nil {
state.errorf("%q is an incomplete or empty template", t.Name())
@@ -260,6 +269,11 @@
// generating output as they go.
func (s *state) walk(dot reflect.Value, node parse.Node) {
s.at(node)
+ // ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates
+ // (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp.
+ if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) {
+ s.errorf("execution interrupted: %w", ErrExecutionInterrupted)
+ }
switch node := node.(type) {
case *parse.ActionNode:
// Do not pop variables so they persist until next end.
diff -ruN a/template.go b/template.go
--- a/template.go 2026-07-08 21:46:30.952848382 +0200
+++ b/template.go 2026-07-08 21:46:30.953952891 +0200
@@ -9,13 +9,15 @@
"reflect"
"sync"
"text/template/parse"
+ "time"
)
// common holds the information shared by related templates.
type common struct {
- tmpl map[string]*Template // Map from name to defined templates.
- muTmpl sync.RWMutex // protects tmpl
- option option
+ tmpl map[string]*Template // Map from name to defined templates.
+ muTmpl sync.RWMutex // protects tmpl
+ option option
+ deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
// We use two maps, one for parsing and one for execution.
// This separation makes the API cleaner since it doesn't
// expose reflection to the client.
@@ -49,6 +51,15 @@
return t.name
}
+// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping
+// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates
+// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.)
+func (t *Template) SetExecutionDeadline(deadline time.Time) *Template {
+ t.init()
+ t.deadline = deadline
+ return t
+}
+
// New allocates a new, undefined template associated with the given one and with the same
// delimiters. The association, which is transitive, allows one template to
// invoke another with a {{template}} action.
+3 -14
View File
@@ -9,15 +9,13 @@ import (
"reflect"
"sync"
"text/template/parse"
"time"
)
// common holds the information shared by related templates.
type common struct {
tmpl map[string]*Template // Map from name to defined templates.
muTmpl sync.RWMutex // protects tmpl
option option
deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
tmpl map[string]*Template // Map from name to defined templates.
muTmpl sync.RWMutex // protects tmpl
option option
// We use two maps, one for parsing and one for execution.
// This separation makes the API cleaner since it doesn't
// expose reflection to the client.
@@ -51,15 +49,6 @@ func (t *Template) Name() string {
return t.name
}
// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping
// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates
// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.)
func (t *Template) SetExecutionDeadline(deadline time.Time) *Template {
t.init()
t.deadline = deadline
return t
}
// New allocates a new, undefined template associated with the given one and with the same
// delimiters. The association, which is transitive, allows one template to
// invoke another with a {{template}} action.
+169
View File
@@ -0,0 +1,169 @@
// Package twilio talks to the Twilio API to make phone calls (for the "Call" feature) and to
// verify phone numbers. It holds the Twilio configuration, so that this functionality is
// decoupled from the ntfy server.
package twilio
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
const (
tagTwilio = "twilio"
)
// Client is the Twilio API client
type Client struct {
config *Config
}
// NewClient creates a new Twilio Client with the given config
func NewClient(config *Config) *Client {
return &Client{config: config}
}
// Call calls the Twilio API to make a phone call to the given phone number, using the given data
func (c *Client) Call(to string, data *CallData) error {
tmpl := defaultCallFormatTemplate
if c.config.CallFormat != nil {
tmpl = c.config.CallFormat
}
var bodyBuf bytes.Buffer
if err := tmpl.Execute(&bodyBuf, data.escaped()); err != nil {
log.Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
return err
}
body := bodyBuf.String()
form := url.Values{}
form.Set("From", c.config.PhoneNumber)
form.Set("To", to)
form.Set("Twiml", body)
ev := log.Tag(tagTwilio).
Field("twilio_to", to).
FieldIf("twilio_body", body, log.TraceLevel).
Debug("Sending Twilio request")
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.CallsBaseURL, c.config.Account)
response, code, err := c.request(requestURL, form)
if err != nil {
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
return err
} else if !success(code) {
// Twilio rejects calls with a 4xx, e.g. for an invalid phone number, or if the account
// is out of funds. Without this check, a rejected call would be counted as a success.
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio call failed with status code %d", code)
return fmt.Errorf("twilio call failed with status code %d", code)
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
return nil
}
// Verify calls the Twilio Verify API to send a verification code to the given phone
// number, via the given channel ("sms" or "call")
func (c *Client) Verify(phoneNumber, channel string) error {
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
form := url.Values{}
form.Set("To", phoneNumber)
form.Set("Channel", channel)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.VerifyBaseURL, c.config.VerifyService)
response, code, err := c.request(requestURL, form)
if err != nil {
ev.Err(err).Warn("Error sending Twilio phone verification request")
return err
} else if !success(code) {
// Without this check, a rejected verification would look like a success to the caller,
// and the user would be told to wait for an SMS that was never sent.
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio phone verification request failed with status code %d", code)
return fmt.Errorf("twilio phone verification request failed with status code %d", code)
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received Twilio phone verification response")
return nil
}
// CheckVerify calls the Twilio Verify API to check the verification code for the given
// phone number. It returns ErrVerificationExpired if the code has expired or never existed.
func (c *Client) CheckVerify(phoneNumber, code string) error {
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
form := url.Values{}
form.Set("To", phoneNumber)
form.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.VerifyBaseURL, c.config.VerifyService)
req, err := c.newRequest(requestURL, form)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if ev.IsTrace() {
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ev.Field("twilio_response", string(response))
}
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
if resp.StatusCode == http.StatusNotFound {
return ErrVerificationExpired
}
return fmt.Errorf("twilio phone verification failed with status code %d", resp.StatusCode)
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if ev.IsTrace() {
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
} else if ev.IsDebug() {
ev.Debug("Received successful Twilio phone verification response")
}
return nil
}
// request POSTs the given form to the given Twilio API URL, and returns the raw response body
// and status code. It does not treat a non-2xx status code as an error; that is up to the
// caller. The response body is returned even if the request failed, so that it can be logged.
func (c *Client) request(requestURL string, form url.Values) (string, int, error) {
req, err := c.newRequest(requestURL, form)
if err != nil {
return "", 0, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
response, err := io.ReadAll(resp.Body)
if err != nil {
return "", resp.StatusCode, err
}
return string(response), resp.StatusCode, nil
}
// success reports whether the given HTTP status code indicates success. Note that the Twilio
// Calls API returns 201 Created (not 200 OK) for a successfully queued call.
func success(code int) bool {
return code >= 200 && code <= 299
}
// newRequest creates a form-encoded POST request against the Twilio API, with the auth and
// User-Agent headers set
func (c *Client) newRequest(requestURL string, form url.Values) (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.Account, c.config.AuthToken))
return req, nil
}
+301
View File
@@ -0,0 +1,301 @@
package twilio
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"text/template"
"github.com/stretchr/testify/require"
)
func TestClient_Call_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
require.Equal(t, "ntfy/1.2.3", r.Header.Get("User-Agent"))
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "+1234567890", form.Get("From"))
require.Equal(t, "+11122233344", form.Get("To"))
require.Contains(t, form.Get("Twiml"), "You have a message from notify on topic mytopic. Message:")
require.Contains(t, form.Get("Twiml"), "hi there")
require.Contains(t, form.Get("Twiml"), "This message was sent by user phil.")
}
// TestClient_Call_EscapesXML ensures that user-controlled fields cannot break out of the
// TwiML document, i.e. that a message containing XML is escaped rather than interpreted
func TestClient_Call_EscapesXML(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
data := &CallData{
Topic: "mytopic",
Message: `</Say><Say>evil</Say>`,
Tags: []string{"<tag>"},
Sender: `phil & "friends"`,
}
require.Nil(t, c.Call("+11122233344", data))
form, err := url.ParseQuery(body)
require.Nil(t, err)
twiml := form.Get("Twiml")
require.NotContains(t, twiml, "<Say>evil</Say>")
require.Contains(t, twiml, "&lt;/Say&gt;&lt;Say&gt;evil&lt;/Say&gt;")
require.Contains(t, twiml, "phil &amp; &#34;friends&#34;")
// The caller's data must not be modified by the escaping
require.Equal(t, `</Say><Say>evil</Say>`, data.Message)
require.Equal(t, []string{"<tag>"}, data.Tags)
}
func TestClient_Call_CustomCallFormat(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
conf := testConfig(server.URL)
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Message}} von {{.Sender}}</Say></Response>`))
c := NewClient(conf)
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "<Response><Say>hi there von phil</Say></Response>", form.Get("Twiml"))
}
// TestClient_Call_RendersAllFields covers the fields that the default TwiML template does not
// use, i.e. Title, Priority and Tags, including the escaping of every tag
func TestClient_Call_RendersAllFields(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
conf := testConfig(server.URL)
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Title}}/{{.Priority}}{{range .Tags}}/{{.}}{{end}}</Say></Response>`))
c := NewClient(conf)
data := &CallData{
Topic: "mytopic",
Title: "a <title>",
Priority: 5,
Tags: []string{"<one>", "two & three"},
}
require.Nil(t, c.Call("+11122233344", data))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "<Response><Say>a &lt;title&gt;/5/&lt;one&gt;/two &amp; three</Say></Response>", form.Get("Twiml"))
}
func TestClient_Call_TemplateError(t *testing.T) {
conf := testConfig("http://dummy.invalid")
conf.CallFormat = template.Must(template.New("twiml").Parse(`{{.DoesNotExist}}`))
c := NewClient(conf)
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic"}))
}
// TestClient_Call_Created ensures that a 201 Created is treated as a success. The Twilio Calls
// API returns 201 (not 200) for a successfully queued call, so this must not be an error.
func TestClient_Call_Created(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"status":"queued"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Call_TwilioError ensures that a non-2xx response from Twilio is returned as an
// error, so that the server counts it as a failure instead of a success. Twilio rejects calls
// with a 4xx, e.g. for an invalid "To" number, or when the account is out of funds.
func TestClient_Call_TwilioError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"code":21211,"message":"Invalid 'To' Phone Number: +invalid"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.Call("+invalid", &CallData{Topic: "mytopic", Message: "hi there"})
require.Error(t, err)
require.Contains(t, err.Error(), "400")
}
func TestClient_Call_TwilioServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Call_TransportError ensures that a call to an unreachable Twilio API returns an
// error, so that the server can count it as a failure
func TestClient_Call_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
func TestClient_Call_InvalidBaseURL(t *testing.T) {
c := NewClient(testConfig("://invalid"))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Verify_Created ensures that a 201 Created is treated as a success. The Twilio
// Verify API returns 201 (not 200) when it creates a verification, so this must not be an error.
func TestClient_Verify_Created(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"status":"pending"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Verify("+12223334444", "sms"))
}
// TestClient_Verify_TwilioError ensures that a non-2xx response from Twilio is returned as an
// error. Without this, no SMS is sent, but the user is still told to check their phone.
func TestClient_Verify_TwilioError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"code":60200,"message":"Invalid parameter"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.Verify("+12223334444", "sms")
require.Error(t, err)
require.Contains(t, err.Error(), "400")
}
func TestClient_Verify_Unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Error(t, c.Verify("+12223334444", "sms"))
}
func TestClient_Verify_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
require.Error(t, c.Verify("+12223334444", "sms"))
}
func TestClient_CheckVerify_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
err := c.CheckVerify("+12223334444", "123456")
require.Error(t, err)
require.False(t, errors.Is(err, ErrVerificationExpired))
}
func TestClient_Verify_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v2/Services/VA1234567890/Verifications", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Verify("+12223334444", "sms"))
require.Equal(t, "Channel=sms&To=%2B12223334444", body)
}
func TestClient_CheckVerify_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v2/Services/VA1234567890/VerificationCheck", r.URL.Path)
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.CheckVerify("+12223334444", "123456"))
require.Equal(t, "Code=123456&To=%2B12223334444", body)
}
// TestClient_CheckVerify_Expired ensures that a 404 from the Twilio Verify API is
// mapped to ErrVerificationExpired, which the server turns into an HTTP 410
func TestClient_CheckVerify_Expired(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.CheckVerify("+12223334444", "123456")
require.True(t, errors.Is(err, ErrVerificationExpired))
}
func TestClient_CheckVerify_OtherError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.CheckVerify("+12223334444", "123456")
require.Error(t, err)
require.False(t, errors.Is(err, ErrVerificationExpired))
}
// closedServerURL returns the URL of a server that is not listening anymore, to simulate an
// unreachable Twilio API
func closedServerURL(t *testing.T) string {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("Should not be called")
}))
server.Close()
return server.URL
}
func testConfig(baseURL string) *Config {
return &Config{
Account: "AC1234567890",
AuthToken: "AAEAA1234567890",
PhoneNumber: "+1234567890",
CallsBaseURL: baseURL,
VerifyBaseURL: baseURL,
VerifyService: "VA1234567890",
BuildVersion: "1.2.3",
}
}
+80
View File
@@ -0,0 +1,80 @@
package twilio
import (
"bytes"
"encoding/xml"
"errors"
"text/template"
)
// ErrVerificationExpired is returned by CheckVerify if the verification code has
// expired, or if it never existed in the first place
var ErrVerificationExpired = errors.New("phone number verification expired or does not exist")
// Config holds the Twilio configuration for the client
type Config struct {
Account string // Twilio account SID, e.g. AC123...
AuthToken string // Twilio auth token
PhoneNumber string // Twilio number to use for outgoing calls
CallsBaseURL string // Base URL of the Twilio Calls API
VerifyBaseURL string // Base URL of the Twilio Verify API
VerifyService string // Twilio Verify service ID, e.g. VA123...
CallFormat *template.Template // TwiML template for calls; if nil, defaultCallFormatTemplate is used
BuildVersion string // ntfy version, used for the User-Agent header
}
// defaultCallFormatTemplate is the default TwiML template used for Twilio calls.
// It can be overridden in the server configuration's twilio-call-format field.
//
// The format uses Go template syntax with the following fields:
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
// String fields are automatically XML-escaped.
var defaultCallFormatTemplate = template.Must(template.New("twiml").Parse(`
<Response>
<Pause length="1"/>
<Say loop="3">
You have a message from notify on topic {{.Topic}}. Message:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
End of message.
<break time="1s"/>
This message was sent by user {{.Sender}}. It will be repeated three times.
To unsubscribe from calls like this, remove your phone number in the notify web app.
<break time="3s"/>
</Say>
<Say>Goodbye.</Say>
</Response>`))
// CallData holds the data passed to the Twilio call format template. String fields are
// XML-escaped before the template is executed, so callers pass them unescaped.
type CallData struct {
Topic string
Title string
Message string
Priority int
Tags []string
Sender string
}
// escaped returns a copy of the call data with all string fields XML-escaped
func (d *CallData) escaped() *CallData {
tags := make([]string, len(d.Tags))
for i, tag := range d.Tags {
tags[i] = xmlEscapeText(tag)
}
return &CallData{
Topic: xmlEscapeText(d.Topic),
Title: xmlEscapeText(d.Title),
Message: xmlEscapeText(d.Message),
Priority: d.Priority,
Tags: tags,
Sender: xmlEscapeText(d.Sender),
}
}
func xmlEscapeText(text string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(text))
return buf.String()
}
+27 -9
View File
@@ -33,6 +33,7 @@ const (
tokenLength = 32
tokenMaxCount = 60 // Only keep this many tokens in the table per user
tag = "user_manager"
schemaStore = "user" // Store name in the schema_version table (see db/schema)
)
// Default constants that may be overridden by configs
@@ -153,24 +154,26 @@ func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
}
}
// Authenticate checks username and password and returns a User if correct, and the user has not been
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
// the password is correct or incorrect.
func (a *Manager) Authenticate(username, password string) (*User, error) {
if username == Everyone {
// Authenticate checks a login identifier (a username or a verified primary email) and password, and
// returns a User if correct and not marked as deleted. The identifier is resolved in a single query
// via userByNameOrEmail, so a user can log in with either their username or their primary
// email. The method returns in constant-ish time (one query, one bcrypt compare), regardless of
// whether the identifier exists or the password is correct or incorrect.
func (a *Manager) Authenticate(identifier, password string) (*User, error) {
if identifier == Everyone {
return nil, ErrUnauthenticated
}
user, err := a.User(username)
user, err := a.userByNameOrEmail(identifier)
if err != nil {
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)")
log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (1)")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated
} else if user.Deleted {
log.Tag(tag).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted")
log.Tag(tag).Field("user_name", identifier).Trace("Authentication of user failed (2): user marked deleted")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)")
log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (3)")
return nil, ErrUnauthenticated
}
return user, nil
@@ -532,6 +535,21 @@ func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
return a.User(identifier)
}
// userByNameOrEmail resolves a login identifier to a single user in one query, matching it
// against the username first and a verified primary email address second. This is the INVERSE
// precedence of UserByEmailOrUsername (used by password reset): at login a freely-chosen username
// must win over a look-alike primary email, so a user whose username happens to equal another
// account's email is not locked out of their own account. Because Authenticate still gates the match
// on a password check, returning the username owner here never grants access to the email owner's
// account. Returns ErrUserNotFound if neither matches.
func (a *Manager) userByNameOrEmail(identifier string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByNameOrPrimaryEmail, identifier, identifier, identifier)
if err != nil {
return nil, err
}
return a.readUser(rows)
}
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
func (a *Manager) userByToken(token string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
+86 -75
View File
@@ -5,6 +5,7 @@ import (
"strings"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
)
// PostgreSQL queries
@@ -33,6 +34,15 @@ const (
LEFT JOIN tier t on t.id = u.tier_id
WHERE user_name = $1
`
postgresSelectUserByNameOrPrimaryEmailQuery = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.user_name = $1
OR u.id = (SELECT user_id FROM user_email WHERE email = $2 AND is_primary)
ORDER BY CASE WHEN u.user_name = $3 THEN 0 ELSE 1 END
LIMIT 1
`
postgresSelectUserByTokenQuery = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
@@ -260,85 +270,86 @@ func postgresSelectAccessCacheUsersQuery(n int) string {
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
var postgresQueries = queries{
selectUserByID: postgresSelectUserByIDQuery,
selectUserByName: postgresSelectUserByNameQuery,
selectUserByToken: postgresSelectUserByTokenQuery,
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
selectUsernames: postgresSelectUsernamesQuery,
selectUsers: postgresSelectUsersQuery,
selectUserCount: postgresSelectUserCountQuery,
selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery,
insertUser: postgresInsertUserQuery,
updateUserPass: postgresUpdateUserPassQuery,
updateUserRole: postgresUpdateUserRoleQuery,
updateUserProvisioned: postgresUpdateUserProvisionedQuery,
updateUserPrefs: postgresUpdateUserPrefsQuery,
updateUserStats: postgresUpdateUserStatsQuery,
updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery,
updateUserTier: postgresUpdateUserTierQuery,
updateUserDeleted: postgresUpdateUserDeletedQuery,
deleteUser: postgresDeleteUserQuery,
deleteUserTier: postgresDeleteUserTierQuery,
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
selectTopicPerms: postgresSelectTopicPermsQuery,
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
selectUserAllAccess: postgresSelectUserAllAccessQuery,
selectUserAccess: postgresSelectUserAccessQuery,
selectUserReservations: postgresSelectUserReservationsQuery,
selectUserReservationsCount: postgresSelectUserReservationsCountQuery,
selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery,
selectUserHasReservation: postgresSelectUserHasReservationQuery,
selectOtherAccessCount: postgresSelectOtherAccessCountQuery,
upsertUserAccess: postgresUpsertUserAccessQuery,
deleteUserAccess: postgresDeleteUserAccessQuery,
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery,
deleteTopicAccess: postgresDeleteTopicAccessQuery,
deleteAllAccess: postgresDeleteAllAccessQuery,
selectToken: postgresSelectTokenQuery,
selectTokens: postgresSelectTokensQuery,
selectTokenCount: postgresSelectTokenCountQuery,
selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery,
upsertToken: postgresUpsertTokenQuery,
updateToken: postgresUpdateTokenQuery,
updateTokenLastAccess: postgresUpdateTokenLastAccessQuery,
deleteToken: postgresDeleteTokenQuery,
deleteProvisionedToken: postgresDeleteProvisionedTokenQuery,
deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery,
deleteAllToken: postgresDeleteAllTokenQuery,
deleteExpiredTokens: postgresDeleteExpiredTokensQuery,
deleteExcessTokens: postgresDeleteExcessTokensQuery,
insertTier: postgresInsertTierQuery,
selectTiers: postgresSelectTiersQuery,
selectTierByCode: postgresSelectTierByCodeQuery,
selectTierByPriceID: postgresSelectTierByPriceIDQuery,
updateTier: postgresUpdateTierQuery,
deleteTier: postgresDeleteTierQuery,
selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
insertPhoneNumber: postgresInsertPhoneNumberQuery,
deletePhoneNumber: postgresDeletePhoneNumberQuery,
selectEmails: postgresSelectEmailsQuery,
insertEmail: postgresInsertEmailQuery,
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
deleteEmail: postgresDeleteEmailQuery,
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
insertMagicLink: postgresInsertMagicLinkQuery,
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
selectPendingEmails: postgresSelectPendingEmailsQuery,
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
updateBilling: postgresUpdateBillingQuery,
selectUserByID: postgresSelectUserByIDQuery,
selectUserByName: postgresSelectUserByNameQuery,
selectUserByNameOrPrimaryEmail: postgresSelectUserByNameOrPrimaryEmailQuery,
selectUserByToken: postgresSelectUserByTokenQuery,
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
selectUsernames: postgresSelectUsernamesQuery,
selectUsers: postgresSelectUsersQuery,
selectUserCount: postgresSelectUserCountQuery,
selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery,
insertUser: postgresInsertUserQuery,
updateUserPass: postgresUpdateUserPassQuery,
updateUserRole: postgresUpdateUserRoleQuery,
updateUserProvisioned: postgresUpdateUserProvisionedQuery,
updateUserPrefs: postgresUpdateUserPrefsQuery,
updateUserStats: postgresUpdateUserStatsQuery,
updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery,
updateUserTier: postgresUpdateUserTierQuery,
updateUserDeleted: postgresUpdateUserDeletedQuery,
deleteUser: postgresDeleteUserQuery,
deleteUserTier: postgresDeleteUserTierQuery,
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
selectTopicPerms: postgresSelectTopicPermsQuery,
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
selectUserAllAccess: postgresSelectUserAllAccessQuery,
selectUserAccess: postgresSelectUserAccessQuery,
selectUserReservations: postgresSelectUserReservationsQuery,
selectUserReservationsCount: postgresSelectUserReservationsCountQuery,
selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery,
selectUserHasReservation: postgresSelectUserHasReservationQuery,
selectOtherAccessCount: postgresSelectOtherAccessCountQuery,
upsertUserAccess: postgresUpsertUserAccessQuery,
deleteUserAccess: postgresDeleteUserAccessQuery,
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery,
deleteTopicAccess: postgresDeleteTopicAccessQuery,
deleteAllAccess: postgresDeleteAllAccessQuery,
selectToken: postgresSelectTokenQuery,
selectTokens: postgresSelectTokensQuery,
selectTokenCount: postgresSelectTokenCountQuery,
selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery,
upsertToken: postgresUpsertTokenQuery,
updateToken: postgresUpdateTokenQuery,
updateTokenLastAccess: postgresUpdateTokenLastAccessQuery,
deleteToken: postgresDeleteTokenQuery,
deleteProvisionedToken: postgresDeleteProvisionedTokenQuery,
deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery,
deleteAllToken: postgresDeleteAllTokenQuery,
deleteExpiredTokens: postgresDeleteExpiredTokensQuery,
deleteExcessTokens: postgresDeleteExcessTokensQuery,
insertTier: postgresInsertTierQuery,
selectTiers: postgresSelectTiersQuery,
selectTierByCode: postgresSelectTierByCodeQuery,
selectTierByPriceID: postgresSelectTierByPriceIDQuery,
updateTier: postgresUpdateTierQuery,
deleteTier: postgresDeleteTierQuery,
selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
insertPhoneNumber: postgresInsertPhoneNumberQuery,
deletePhoneNumber: postgresDeletePhoneNumberQuery,
selectEmails: postgresSelectEmailsQuery,
insertEmail: postgresInsertEmailQuery,
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
deleteEmail: postgresDeleteEmailQuery,
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
insertMagicLink: postgresInsertMagicLinkQuery,
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
selectPendingEmails: postgresSelectPendingEmailsQuery,
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
updateBilling: postgresUpdateBillingQuery,
}
// NewPostgresManager creates a new Manager backed by a PostgreSQL database
func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) {
if err := setupPostgres(d.Primary()); err != nil {
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
return nil, err
}
return newManager(d, postgresQueries, config)
+11 -65
View File
@@ -1,8 +1,7 @@
package user
import (
"database/sql"
"fmt"
"heckel.io/ntfy/v2/db/schema"
)
// Initial PostgreSQL schema
@@ -90,21 +89,14 @@ const (
PRIMARY KEY (token_hash)
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING;
`
)
// Schema table management queries for Postgres
const (
postgresCurrentSchemaVersion = 8
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
postgresCurrentSchemaVersion = 9
)
const (
@@ -133,62 +125,16 @@ const (
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
`
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
)
var postgresMigrations = map[int]func(db *sql.DB) error{
6: postgresMigrateFrom6,
7: postgresMigrateFrom7,
}
var (
postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQueries)
func setupPostgres(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewPostgres(db)
// postgresMigrations maps a schema version to the migration upgrading it to the next
// version. Always append migrations at the end, never insert in the middle.
postgresMigrations = map[int]schema.MigrateFunc{
6: schema.AsMigrateFunc(postgresMigrate6To7UpdateQueries),
7: schema.AsMigrateFunc(postgresMigrate7To8UpdateQueries),
8: schema.NopMigrateFunc, // 8 -> 9 repairs a SQLite-only foreign key defect; nothing to do on Postgres
}
if schemaVersion == postgresCurrentSchemaVersion {
return nil
} else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
}
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
fn, ok := postgresMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func postgresMigrateFrom6(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
}
func postgresMigrateFrom7(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
}
func setupNewPostgres(db *sql.DB) error {
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
return err
}
if _, err := db.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
return err
}
return nil
}
)
+90 -76
View File
@@ -9,6 +9,7 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/util"
)
@@ -37,6 +38,15 @@ const (
LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ?
`
sqliteSelectUserByNameOrPrimaryEmailQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.user = ?
OR u.id = (SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1)
ORDER BY CASE WHEN u.user = ? THEN 0 ELSE 1 END
LIMIT 1
`
sqliteSelectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
@@ -256,80 +266,81 @@ func sqliteSelectAccessCacheUsersQuery(n int) string {
}
var sqliteQueries = queries{
selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery,
selectUserByToken: sqliteSelectUserByTokenQuery,
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
selectUsernames: sqliteSelectUsernamesQuery,
selectUsers: sqliteSelectUsersQuery,
selectUserCount: sqliteSelectUserCountQuery,
selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery,
insertUser: sqliteInsertUserQuery,
updateUserPass: sqliteUpdateUserPassQuery,
updateUserRole: sqliteUpdateUserRoleQuery,
updateUserProvisioned: sqliteUpdateUserProvisionedQuery,
updateUserPrefs: sqliteUpdateUserPrefsQuery,
updateUserStats: sqliteUpdateUserStatsQuery,
updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery,
updateUserTier: sqliteUpdateUserTierQuery,
updateUserDeleted: sqliteUpdateUserDeletedQuery,
deleteUser: sqliteDeleteUserQuery,
deleteUserTier: sqliteDeleteUserTierQuery,
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
selectTopicPerms: sqliteSelectTopicPermsQuery,
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
selectUserAccess: sqliteSelectUserAccessQuery,
selectUserReservations: sqliteSelectUserReservationsQuery,
selectUserReservationsCount: sqliteSelectUserReservationsCountQuery,
selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery,
selectUserHasReservation: sqliteSelectUserHasReservationQuery,
selectOtherAccessCount: sqliteSelectOtherAccessCountQuery,
upsertUserAccess: sqliteUpsertUserAccessQuery,
deleteUserAccess: sqliteDeleteUserAccessQuery,
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery,
deleteTopicAccess: sqliteDeleteTopicAccessQuery,
deleteAllAccess: sqliteDeleteAllAccessQuery,
selectToken: sqliteSelectTokenQuery,
selectTokens: sqliteSelectTokensQuery,
selectTokenCount: sqliteSelectTokenCountQuery,
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery,
upsertToken: sqliteUpsertTokenQuery,
updateToken: sqliteUpdateTokenQuery,
updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery,
deleteToken: sqliteDeleteTokenQuery,
deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery,
deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery,
deleteAllToken: sqliteDeleteAllTokenQuery,
deleteExpiredTokens: sqliteDeleteExpiredTokensQuery,
deleteExcessTokens: sqliteDeleteExcessTokensQuery,
insertTier: sqliteInsertTierQuery,
selectTiers: sqliteSelectTiersQuery,
selectTierByCode: sqliteSelectTierByCodeQuery,
selectTierByPriceID: sqliteSelectTierByPriceIDQuery,
updateTier: sqliteUpdateTierQuery,
deleteTier: sqliteDeleteTierQuery,
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
insertPhoneNumber: sqliteInsertPhoneNumberQuery,
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
selectEmails: sqliteSelectEmailsQuery,
insertEmail: sqliteInsertEmailQuery,
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
deleteEmail: sqliteDeleteEmailQuery,
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
insertMagicLink: sqliteInsertMagicLinkQuery,
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
selectPendingEmails: sqliteSelectPendingEmailsQuery,
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
updateBilling: sqliteUpdateBillingQuery,
selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery,
selectUserByNameOrPrimaryEmail: sqliteSelectUserByNameOrPrimaryEmailQuery,
selectUserByToken: sqliteSelectUserByTokenQuery,
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
selectUsernames: sqliteSelectUsernamesQuery,
selectUsers: sqliteSelectUsersQuery,
selectUserCount: sqliteSelectUserCountQuery,
selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery,
insertUser: sqliteInsertUserQuery,
updateUserPass: sqliteUpdateUserPassQuery,
updateUserRole: sqliteUpdateUserRoleQuery,
updateUserProvisioned: sqliteUpdateUserProvisionedQuery,
updateUserPrefs: sqliteUpdateUserPrefsQuery,
updateUserStats: sqliteUpdateUserStatsQuery,
updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery,
updateUserTier: sqliteUpdateUserTierQuery,
updateUserDeleted: sqliteUpdateUserDeletedQuery,
deleteUser: sqliteDeleteUserQuery,
deleteUserTier: sqliteDeleteUserTierQuery,
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
selectTopicPerms: sqliteSelectTopicPermsQuery,
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
selectUserAccess: sqliteSelectUserAccessQuery,
selectUserReservations: sqliteSelectUserReservationsQuery,
selectUserReservationsCount: sqliteSelectUserReservationsCountQuery,
selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery,
selectUserHasReservation: sqliteSelectUserHasReservationQuery,
selectOtherAccessCount: sqliteSelectOtherAccessCountQuery,
upsertUserAccess: sqliteUpsertUserAccessQuery,
deleteUserAccess: sqliteDeleteUserAccessQuery,
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery,
deleteTopicAccess: sqliteDeleteTopicAccessQuery,
deleteAllAccess: sqliteDeleteAllAccessQuery,
selectToken: sqliteSelectTokenQuery,
selectTokens: sqliteSelectTokensQuery,
selectTokenCount: sqliteSelectTokenCountQuery,
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery,
upsertToken: sqliteUpsertTokenQuery,
updateToken: sqliteUpdateTokenQuery,
updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery,
deleteToken: sqliteDeleteTokenQuery,
deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery,
deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery,
deleteAllToken: sqliteDeleteAllTokenQuery,
deleteExpiredTokens: sqliteDeleteExpiredTokensQuery,
deleteExcessTokens: sqliteDeleteExcessTokensQuery,
insertTier: sqliteInsertTierQuery,
selectTiers: sqliteSelectTiersQuery,
selectTierByCode: sqliteSelectTierByCodeQuery,
selectTierByPriceID: sqliteSelectTierByPriceIDQuery,
updateTier: sqliteUpdateTierQuery,
deleteTier: sqliteDeleteTierQuery,
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
insertPhoneNumber: sqliteInsertPhoneNumberQuery,
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
selectEmails: sqliteSelectEmailsQuery,
insertEmail: sqliteInsertEmailQuery,
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
deleteEmail: sqliteDeleteEmailQuery,
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
insertMagicLink: sqliteInsertMagicLinkQuery,
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
selectPendingEmails: sqliteSelectPendingEmailsQuery,
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
updateBilling: sqliteUpdateBillingQuery,
}
// NewSQLiteManager creates a new Manager backed by a SQLite database
@@ -341,14 +352,17 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager
// Open with case-sensitive LIKE. ACL topic matching is done via LIKE (see
// selectTopicPerms), and SQLite's LIKE is case-insensitive for ASCII by
// default -- without this, an ACL rule for "secret" would also match a
// request for "SECRET", which is a security iisue. PostgreSQL's LIKE is
// request for "SECRET", which is a security issue. PostgreSQL's LIKE is
// already case-sensitive, so this only affects SQLite. The pragma is
// applied to every pooled connection by the driver.
d, err := sql.Open("sqlite3", fmt.Sprintf("%s?_case_sensitive_like=on", filename))
if err != nil {
return nil, err
}
if err := setupSQLite(d); err != nil {
// Migrations must run before the startup queries: the 5 -> 6 table rebuilds rely on
// foreign keys being OFF, which is only guaranteed on fresh connections (the foreign_keys
// pragma is enabled as part of the builtin startup queries below)
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations); err != nil {
return nil, err
}
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
+64 -175
View File
@@ -2,10 +2,8 @@ package user
import (
"database/sql"
"fmt"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/util"
)
@@ -105,10 +103,6 @@ const (
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
@@ -119,12 +113,8 @@ const (
sqliteBuiltinStartupQueries = `PRAGMA foreign_keys = ON;`
)
// Schema version table management for SQLite
const (
sqliteCurrentSchemaVersion = 8
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
sqliteCurrentSchemaVersion = 9
)
// Schema migrations for SQLite
@@ -190,10 +180,6 @@ const (
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
@@ -270,10 +256,11 @@ const (
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
`
// 5 -> 6
// 5 -> 6. The table rebuilds below rely on foreign keys being OFF (otherwise RENAME
// would rewrite the childrens' REFERENCES clauses to point at the _old tables). This is
// guaranteed because migrations run on fresh connections, before the startup queries
// enable the foreign_keys pragma; see NewSQLiteManager.
sqliteMigrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user (
@@ -359,57 +346,45 @@ const (
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
`
-- Re-enable foreign keys
PRAGMA foreign_keys=on;
// 8 -> 9: Repair the user_phone foreign key. The 5 -> 6 migration renamed user to
// user_old, which rewrote user_phone's REFERENCES clause to user_old -- a table that was
// then dropped (the rebuilt tables got correct fresh foreign keys; user_phone was the only
// child table not rebuilt). Rebuilding user_phone re-points the foreign key at user; on
// healthy databases the rebuild is a harmless no-op schema-wise.
sqliteMigrate8To9UpdateQueries = `
ALTER TABLE user_phone RENAME TO user_phone_old;
CREATE TABLE user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_phone (user_id, phone_number)
SELECT user_id, phone_number FROM user_phone_old
WHERE user_id IN (SELECT id FROM user); -- Drop orphaned rows that the broken foreign key failed to cascade-delete
DROP TABLE user_phone_old;
`
)
var (
sqliteMigrations = map[int]func(db *sql.DB) error{
sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQueries)
// sqliteMigrations maps a schema version to the migration upgrading it to the next
// version. Always append migrations at the end, never insert in the middle.
sqliteMigrations = map[int]schema.MigrateFunc{
1: sqliteMigrateFrom1,
2: sqliteMigrateFrom2,
3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
6: sqliteMigrateFrom6,
7: sqliteMigrateFrom7,
2: schema.AsMigrateFunc(sqliteMigrate2To3UpdateQueries),
3: schema.AsMigrateFunc(sqliteMigrate3To4UpdateQueries),
4: schema.AsMigrateFunc(sqliteMigrate4To5UpdateQueries),
5: schema.AsMigrateFunc(sqliteMigrate5To6UpdateQueries),
6: schema.AsMigrateFunc(sqliteMigrate6To7UpdateQueries),
7: schema.AsMigrateFunc(sqliteMigrate7To8UpdateQueries),
8: schema.AsMigrateFunc(sqliteMigrate8To9UpdateQueries),
}
)
func setupSQLite(db *sql.DB) error {
var schemaVersion int
if err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
return setupNewSQLite(db)
}
if schemaVersion == sqliteCurrentSchemaVersion {
return nil
} else if schemaVersion > sqliteCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
}
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
fn, ok := sqliteMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func setupNewSQLite(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteCreateTablesQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil {
return err
@@ -422,122 +397,36 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
return nil
}
func sqliteMigrateFrom1(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 1 to 2")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
// Rename user -> user_old, and create new tables
if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {
func sqliteMigrateFrom1(tx *sql.Tx) error {
// Rename user -> user_old, and create new tables
if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {
return err
}
// Insert users from user_old into new user table, with ID and sync_topic
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery)
if err != nil {
return err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return err
}
// Insert users from user_old into new user table, with ID and sync_topic
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery)
if err != nil {
usernames = append(usernames, username)
}
if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {
return err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return err
}
usernames = append(usernames, username)
}
if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {
return err
}
}
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
if _, err := tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom2(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom3(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom4(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 4 to 5")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom5(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom6(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 6 to 7")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom7(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 7 to 8")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
})
}
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
_, err = tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery)
return err
}
+249 -2
View File
@@ -14,6 +14,7 @@ import (
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/db/schema"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/util"
)
@@ -1566,6 +1567,83 @@ func TestToFromSQLWildcard(t *testing.T) {
require.Equal(t, "foo", fromSQLWildcard(toSQLWildcard("foo")))
}
// testPostgresV6Schema is the PostgreSQL schema exactly as created by the version that first
// shipped Postgres support (schema version 6), taken from the code at that time; used to
// verify the migration chain from its oldest supported version.
const testPostgresV6Schema = `
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit BIGINT NOT NULL,
messages_expiry_duration BIGINT NOT NULL,
emails_limit BIGINT NOT NULL,
calls_limit BIGINT NOT NULL,
reservations_limit BIGINT NOT NULL,
attachment_file_size_limit BIGINT NOT NULL,
attachment_total_size_limit BIGINT NOT NULL,
attachment_expiry_duration BIGINT NOT NULL,
attachment_bandwidth_limit BIGINT NOT NULL,
stripe_monthly_price_id TEXT,
stripe_yearly_price_id TEXT,
UNIQUE(code),
UNIQUE(stripe_monthly_price_id),
UNIQUE(stripe_yearly_price_id)
);
CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY,
tier_id TEXT REFERENCES tier(id),
user_name TEXT NOT NULL UNIQUE,
pass TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('anonymous', 'admin', 'user')),
prefs JSONB NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned BOOLEAN NOT NULL,
stats_messages BIGINT NOT NULL DEFAULT 0,
stats_emails BIGINT NOT NULL DEFAULT 0,
stats_calls BIGINT NOT NULL DEFAULT 0,
stripe_customer_id TEXT UNIQUE,
stripe_subscription_id TEXT UNIQUE,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until BIGINT,
stripe_subscription_cancel_at BIGINT,
created BIGINT NOT NULL,
deleted BIGINT
);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
topic TEXT NOT NULL,
read BOOLEAN NOT NULL,
write BOOLEAN NOT NULL,
owner_user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE,
provisioned BOOLEAN NOT NULL,
PRIMARY KEY (user_id, topic)
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
last_access BIGINT NOT NULL,
last_origin TEXT NOT NULL,
expires BIGINT NOT NULL,
provisioned BOOLEAN NOT NULL,
PRIMARY KEY (user_id, token)
);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number)
);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING;
`
func TestMigrationFrom1(t *testing.T) {
filename := filepath.Join(t.TempDir(), "user.db")
db, err := sql.Open("sqlite3", filename)
@@ -1649,6 +1727,8 @@ func TestMigrationFrom1(t *testing.T) {
require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
require.Equal(t, PermissionRead, everyoneGrants[0].Permission)
checkMigratedSqliteSchema(t, filename)
}
func TestMigrationFrom4(t *testing.T) {
@@ -1740,12 +1820,15 @@ func TestMigrationFrom4(t *testing.T) {
`)
require.Nil(t, err)
// Insert a few ACL entries
// Insert a few ACL entries, and phone numbers: one for a live user, one orphaned (its user
// is gone; the broken pre-v9 foreign key never cascade-deleted it)
_, err = db.Exec(`
BEGIN;
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'mytopic_', 1, 1);
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'up%', 1, 1);
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'down_%', 1, 1);
INSERT INTO user_phone (user_id, phone_number) VALUES ('u_everyone', '+12223334444');
INSERT INTO user_phone (user_id, phone_number) VALUES ('u_gone', '+15556667777');
COMMIT;
`)
require.Nil(t, err)
@@ -1795,6 +1878,68 @@ func TestMigrationFrom4(t *testing.T) {
require.Nil(t, a.Authorize(nil, "up123", PermissionRead))
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
// The 8 -> 9 repair kept the live user's phone number and dropped the orphaned row
phoneNumbers := make([]string, 0)
rows, err = db.Query(`SELECT phone_number FROM user_phone ORDER BY phone_number`)
require.Nil(t, err)
for rows.Next() {
var phoneNumber string
require.Nil(t, rows.Scan(&phoneNumber))
phoneNumbers = append(phoneNumbers, phoneNumber)
}
require.Nil(t, rows.Close())
require.Equal(t, []string{"+12223334444"}, phoneNumbers)
checkMigratedSqliteSchema(t, filename)
}
// TestMigrationFrom6Postgres tests the Postgres migration chain from its oldest supported
// version (6, the version PostgreSQL support first shipped with).
func TestMigrationFrom6Postgres(t *testing.T) {
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(testPostgresV6Schema)
require.Nil(t, err)
_, err = testDB.Exec(`INSERT INTO schema_version (store, version) VALUES ('user', 6)`)
require.Nil(t, err)
// Create manager to trigger migration
a, err := NewPostgresManager(testDB, &Config{DefaultAccess: PermissionDenyAll, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval})
require.Nil(t, err)
var version int
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'user'`).Scan(&version))
require.Equal(t, postgresCurrentSchemaVersion, version)
// The manager works against the migrated schema
require.Nil(t, a.AddUser("phil", "mypass", RoleUser, false))
u, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
// The migrated database must be structurally identical to a freshly created one
freshDB := dbtest.CreateTestPostgres(t)
_, err = NewPostgresManager(freshDB, &Config{DefaultAccess: PermissionDenyAll, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval})
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
}
// checkMigratedSqliteSchema verifies that a migrated database is structurally identical to a
// freshly created one (this pins, among other things, that the foreign keys of the tables
// rebuilt in migration 5 -> 6 still point at "user", not at a dropped "user_old"), and that
// its data passes SQLite's foreign key consistency check.
func checkMigratedSqliteSchema(t *testing.T, filename string) {
t.Helper()
freshFile := filepath.Join(t.TempDir(), "fresh.db")
fresh := newTestManagerFromFile(t, freshFile, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
defer fresh.Close()
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
rows, err := migratedDB.Query(`PRAGMA foreign_key_check`)
require.Nil(t, err)
defer rows.Close()
require.False(t, rows.Next(), "foreign_key_check reported violations in the migrated database")
}
func checkSchemaVersion(t *testing.T, d *db.DB) {
@@ -2947,6 +3092,108 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
})
}
func TestManager_Authenticate_ByPrimaryEmail(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
// phil verifies phil@example.com -> becomes his primary (recovery) email
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour))
require.Nil(t, err)
// Login by username still works
u, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// Login by primary email works and resolves to the same account
u, err = a.Authenticate("phil@example.com", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// Login by primary email with the wrong password fails
u, err = a.Authenticate("phil@example.com", "wrong")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
// An unknown email fails
u, err = a.Authenticate("nobody@example.com", "phil")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
})
}
func TestManager_Authenticate_BySecondaryEmailDenied(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
ben, err := a.User("ben")
require.Nil(t, err)
// phil verifies shared@ first -> his primary; ben verifies it too -> only secondary for ben
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
// Login by the shared address resolves to the primary owner (phil), never the secondary (ben)
u, err := a.Authenticate("shared@example.com", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// ben's password must not authenticate via the shared address (it is not his primary)
u, err = a.Authenticate("shared@example.com", "ben")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
})
}
func TestManager_Authenticate_UsernameLookalikeEmailPrecedence(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
// The collision: a squatter whose USERNAME is literally "phil@example.com" (usernames may
// contain '@' and '.'), and a different account (ben) that owns "phil@example.com" as its
// verified primary email. Both are reachable; nothing links usernames to email addresses.
require.Nil(t, a.AddUser("phil@example.com", "squatterpass", RoleUser, false))
require.Nil(t, a.AddUser("ben", "benpass", RoleUser, false))
ben, err := a.User("ben")
require.Nil(t, err)
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "phil@example.com", 24*time.Hour))
require.Nil(t, err)
// Login resolves the ambiguous identifier username-FIRST (the ORDER BY CASE in the query):
// the squatter owns the login, and returns deterministically even though both rows match.
squatter, err := a.Authenticate("phil@example.com", "squatterpass")
require.Nil(t, err)
require.Equal(t, "phil@example.com", squatter.Name)
// Consequently the email owner's password does NOT authenticate via the colliding identifier
// at login, but the owner is not locked out: their real username still works.
u, err := a.Authenticate("phil@example.com", "benpass")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
u, err = a.Authenticate("ben", "benpass")
require.Nil(t, err)
require.Equal(t, "ben", u.Name)
// The inverse: password reset (UserByEmailOrUsername) resolves the SAME identifier email-FIRST,
// so the reset link goes to the verified email owner (ben), never the look-alike username. The
// two flows deliberately use opposite precedence.
loginUser, err := a.userByNameOrEmail("phil@example.com")
require.Nil(t, err)
require.Equal(t, "phil@example.com", loginUser.Name) // username owner (squatter)
resetUser, err := a.UserByEmailOrUsername("phil@example.com")
require.Nil(t, err)
require.Equal(t, "ben", resetUser.Name) // email owner
})
}
func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
@@ -3250,7 +3497,7 @@ func TestManager_Emails_PrimaryFlagAndHelpers(t *testing.T) {
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
require.Nil(t, err)
require.Nil(t, setupSQLite(d))
require.Nil(t, schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations))
return d
}
+26 -25
View File
@@ -47,10 +47,10 @@ func (u *User) IsUser() bool {
// Auther is an interface for authentication and authorization
type Auther interface {
// Authenticate checks username and password and returns a user if correct. The method
// returns in constant-ish time, regardless of whether the user exists or the password is
// correct or incorrect.
Authenticate(username, password string) (*User, error)
// Authenticate checks a login identifier (username or verified primary email) and password
// and returns a user if correct. The method returns in constant-ish time, regardless of
// whether the identifier exists or the password is correct or incorrect.
Authenticate(identifier, password string) (*User, error)
// Authorize returns nil if the given user has access to the given topic using the desired
// permission. The user param may be nil to signal an anonymous user.
@@ -338,27 +338,28 @@ var (
// queries holds the database-specific SQL queries
type queries struct {
// User queries
selectUserByID string
selectUserByName string
selectUserByToken string
selectUserByStripeCustomerID string
selectUsernames string
selectUsers string
selectUserCount string
selectUserIDFromUsername string
insertUser string
updateUserPass string
updateUserRole string
updateUserProvisioned string
updateUserPrefs string
updateUserStats string
updateUserStatsResetAll string
updateUserTier string
updateUserDeleted string
deleteUser string
deleteUserTier string
deleteUsersMarked string
deleteUsersProvisioned string
selectUserByID string
selectUserByName string
selectUserByNameOrPrimaryEmail string
selectUserByToken string
selectUserByStripeCustomerID string
selectUsernames string
selectUsers string
selectUserCount string
selectUserIDFromUsername string
insertUser string
updateUserPass string
updateUserRole string
updateUserProvisioned string
updateUserPrefs string
updateUserStats string
updateUserStatsResetAll string
updateUserTier string
updateUserDeleted string
deleteUser string
deleteUserTier string
deleteUsersMarked string
deleteUsersProvisioned string
// Access queries
selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled
+1
View File
@@ -12,6 +12,7 @@ const (
loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long
stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues
sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues
indentSpacesLimit = 100 // Limit indentation width to prevent memory issues; indent allocates spaces*lines bytes
)
// TxtFuncMap produces the function map.
+7
View File
@@ -116,6 +116,7 @@ func cat(v ...any) string {
}
// indent adds a specified number of spaces at the beginning of each line in a string.
// It has a safety limit to prevent excessive memory usage.
//
// Parameters:
// - spaces: The number of spaces to add
@@ -123,7 +124,13 @@ func cat(v ...any) string {
//
// Returns:
// - string: The indented string
//
// Panics:
// - If spaces exceeds indentSpacesLimit
func indent(spaces int, v string) string {
if spaces > indentSpacesLimit {
panic(fmt.Sprintf("indent %d exceeds limit of %d", spaces, indentSpacesLimit))
}
pad := strings.Repeat(" ", spaces)
return pad + strings.Replace(v, "\n", "\n"+pad, -1)
}
+20
View File
@@ -4,6 +4,7 @@ import (
"encoding/base32"
"encoding/base64"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -214,6 +215,25 @@ func TestNindent(t *testing.T) {
}
}
func TestIndentLimit(t *testing.T) {
// Indentation beyond a sane width is an amplification attempt: indent allocates
// spaces * lines bytes in a single uninterruptible call
if err := runt(`{{indent 100 "a"}}`, strings.Repeat(" ", 100)+"a"); err != nil {
t.Error(err)
}
for _, tpl := range []string{
`{{indent 101 "a"}}`,
`{{nindent 101 "a"}}`,
`{{indent 1000000000 "a"}}`,
} {
if _, err := runRaw(tpl, nil); err == nil {
t.Errorf("expected %s to be rejected", tpl)
} else if !strings.Contains(err.Error(), "exceeds limit") {
t.Errorf("expected limit error for %s, got: %v", tpl, err)
}
}
}
func TestReplace(t *testing.T) {
tpl := `{{"I Am Henry VIII" | replace " " "-"}}`
if err := runt(tpl, "I-Am-Henry-VIII"); err != nil {
+435 -468
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@
"i18next-http-backend": "^4.0.0",
"react": "latest",
"react-dom": "latest",
"react-i18next": "^11.16.2",
"react-i18next": "^17.0.11",
"react-infinite-scroll-component": "^7.2.1",
"react-remark": "^2.1.0",
"react-router-dom": "^6.30.4",
+1
View File
@@ -26,6 +26,7 @@
"signup_error_username_taken": "Username {{username}} is already taken",
"signup_error_creation_limit_reached": "Account creation limit reached",
"login_title": "Sign in to your ntfy account",
"login_form_username_label": "Username or email",
"login_form_button_submit": "Sign in",
"login_link_signup": "Sign up",
"login_link_forgot_password": "Forgot password",
+38 -37
View File
@@ -9,44 +9,44 @@
"action_bar_account": "Konts",
"action_bar_profile_title": "Profils",
"action_bar_profile_settings": "Iestatījumi",
"action_bar_profile_logout": "Iziet",
"action_bar_profile_logout": "Atteikties",
"nav_button_account": "Konts",
"nav_button_settings": "Iestatījumi",
"nav_button_documentation": "Dokumentācija",
"nav_button_connecting": "savienojas",
"notifications_list_item": "Paziņojums",
"notifications_delete": "Dzēst",
"notifications_delete": "Izdzēst",
"notifications_tags": "Birkas",
"notifications_example": "Piemērs",
"publish_dialog_title_label": "Virsraksts",
"publish_dialog_message_label": "Ziņojums",
"publish_dialog_tags_label": "Birkas",
"publish_dialog_priority_label": "Prioritāte",
"publish_dialog_priority_label": "Svarīgums",
"publish_dialog_email_label": "E-pasta adrese",
"publish_dialog_filename_label": "Datnes nosaukums",
"publish_dialog_delay_label": "Aizkave",
"publish_dialog_button_cancel": "Atcelt",
"publish_dialog_button_send": "Sūtīt",
"publish_dialog_button_send": "Nosūtīt",
"subscribe_dialog_subscribe_button_cancel": "Atcelt",
"subscribe_dialog_subscribe_button_subscribe": "Abonēt",
"subscribe_dialog_login_password_label": "Parole",
"subscribe_dialog_error_user_anonymous": "anonīms lietotājs",
"subscribe_dialog_error_user_anonymous": "nezināms lietotājs",
"account_basics_title": "Konts",
"account_basics_username_title": "Lietotājvārds",
"account_basics_password_title": "Parole",
"account_basics_phone_numbers_dialog_channel_sms": "Nosūtīt īsziņu",
"account_basics_phone_numbers_dialog_channel_call": "Zvanīt",
"account_basics_phone_numbers_dialog_channel_sms": "Īsziņa",
"account_basics_phone_numbers_dialog_channel_call": "Zvans",
"account_usage_title": "Lietojums",
"account_usage_unlimited": "Neierobežots",
"account_basics_tier_admin": "Administrators",
"account_basics_tier_admin": "Pārvaldītājs",
"account_basics_tier_basic": "Pamata",
"account_basics_tier_free": "Bezmaksas",
"account_basics_tier_interval_monthly": "ikmēnesi",
"account_basics_tier_interval_monthly": "ik mēnesi",
"account_basics_tier_interval_yearly": "katru gadu",
"account_basics_tier_change_button": "Mainīt",
"account_delete_dialog_label": "Parole",
"account_delete_dialog_button_cancel": "Atcelt",
"account_upgrade_dialog_interval_monthly": "Ikmēnesi",
"account_upgrade_dialog_interval_monthly": "Ik mēnesi",
"account_upgrade_dialog_interval_yearly": "Katru gadu",
"account_upgrade_dialog_tier_price_per_month": "mēnesī",
"account_upgrade_dialog_tier_selected_label": "Atlasīts",
@@ -62,25 +62,25 @@
"prefs_users_dialog_password_label": "Parole",
"prefs_appearance_title": "Izskats",
"prefs_appearance_language_title": "Valoda",
"prefs_appearance_theme_title": "Motīvs",
"prefs_appearance_theme_title": "Krāsu palete",
"prefs_reservations_table_topic_header": "Tēma",
"prefs_reservations_table_access_header": "Piekļuve",
"prefs_reservations_dialog_topic_label": "Tēma",
"prefs_reservations_dialog_access_label": "Piekļuve",
"priority_min": "minimālā",
"priority_low": "zema",
"priority_min": "viszemākais",
"priority_low": "zems",
"priority_default": "noklusējuma",
"priority_high": "augsta",
"priority_max": "maksimālā",
"signup_form_confirm_password": "Atkārtot paroli",
"priority_high": "augsts",
"priority_max": "visaugstākais",
"signup_form_confirm_password": "Apstiprināt paroli",
"signup_form_button_submit": "Izveidot kontu",
"login_link_signup": "Izveidot kontu",
"action_bar_show_menu": "Rādīt izvēlni",
"action_bar_logo_alt": "ntfy logotips",
"action_bar_reservation_add": "Rezervēt tēmu",
"action_bar_reservation_edit": "Mainīt rezervāciju",
"action_bar_reservation_delete": "Noņemt rezervāciju",
"action_bar_reservation_limit_reached": "Sasniegts limits",
"action_bar_reservation_add": "Aizņemt tēmu",
"action_bar_reservation_edit": "Mainīt aizņemšanu",
"action_bar_reservation_delete": "Noņemt aizņemšanu",
"action_bar_reservation_limit_reached": "Sasniegts ierobežojums",
"action_bar_mute_notifications": "Apklusināt paziņojumus",
"action_bar_sign_up": "Izveidot kontu",
"message_bar_publish": "Publicēt ziņojumu",
@@ -88,38 +88,39 @@
"nav_button_all_notifications": "Visi paziņojumi",
"nav_button_publish_message": "Publicēt paziņojumu",
"nav_button_muted": "Paziņojumi apklusināti",
"alert_notification_permission_required_button": "Dot tagad",
"alert_notification_permission_required_button": "Piešķirt tagad",
"notifications_list": "Paziņojumu saraksts",
"notifications_priority_x": "{{priority}} prioritāte",
"notifications_priority_x": "{{priority}} svarīgums",
"notifications_new_indicator": "Jauns paziņojums",
"notifications_attachment_image": "Pielikuma attēls",
"notifications_attachment_copy_url_button": "Kopēt URL adresi",
"notifications_attachment_copy_url_button": "Ievietot URL starpliktuvē",
"notifications_attachment_open_button": "Atvērt pielikumu",
"notifications_attachment_file_image": "attēla datne",
"notifications_attachment_file_video": "video datne",
"notifications_attachment_file_audio": "audio datne",
"notifications_attachment_file_document": "cits datnes tips",
"notifications_click_copy_url_button": "Kopēt saiti",
"notifications_attachment_file_audio": "skaņas datne",
"notifications_attachment_file_document": "cits datnes veids",
"notifications_click_copy_url_button": "Ievietot saiti starpliktuvē",
"notifications_click_open_button": "Atvērt saiti",
"notifications_actions_failed_notification": "Neveiksmīga darbība",
"notifications_actions_failed_notification": "Nesekmīga darbība",
"publish_dialog_title_no_topic": "Publicēt paziņojumu",
"publish_dialog_progress_uploading": "Augšupielādē …",
"publish_dialog_message_published": "Paziņojums publicēts",
"publish_dialog_message_published": "Paziņojums izdots",
"publish_dialog_emoji_picker_show": "Atlasīt emocijzīmi",
"publish_dialog_priority_min": "Minimāla prioritāte",
"publish_dialog_priority_low": "Zema prioritāte",
"publish_dialog_priority_default": "Noklusējuma prioritāte",
"publish_dialog_priority_high": "Augsta prioritāte",
"publish_dialog_priority_max": "Maksimāla prioritāte",
"publish_dialog_base_url_label": "Pakalpojuma URL adrese",
"publish_dialog_priority_min": "Viszemākais svarīgums",
"publish_dialog_priority_low": "Zems svarīgums",
"publish_dialog_priority_default": "Noklusējuma svarīgums",
"publish_dialog_priority_high": "Augsts svarīgums",
"publish_dialog_priority_max": "Visaugstākais svarīgums",
"publish_dialog_base_url_label": "Pakalpojuma URL",
"publish_dialog_topic_label": "Tēmas nosaukums",
"publish_dialog_topic_reset": "Atiestatīt tēmu",
"publish_dialog_click_label": "Klikšķināma URL adrese",
"publish_dialog_call_label": "Tālruņa zvans",
"publish_dialog_attach_label": "Pielikuma URL adrese",
"publish_dialog_attach_label": "Pielikuma URL",
"publish_dialog_filename_placeholder": "Pielikuma datnes nosaukums",
"publish_dialog_other_features": "Citas funkcijas:",
"publish_dialog_other_features": "Citas iespējas:",
"publish_dialog_chip_call_label": "Tālruņa zvans",
"publish_dialog_chip_delay_label": "Aizkavēt piegādi",
"publish_dialog_chip_topic_label": "Mainīt tēmu"
"publish_dialog_chip_topic_label": "Mainīt tēmu",
"common_close": "Aizvērt"
}
+57 -3
View File
@@ -232,7 +232,7 @@
"account_usage_messages_title": "Mensagens publicadas",
"account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS",
"account_basics_tier_change_button": "Mudar",
"account_basics_tier_admin_suffix_with_tier": "(com nível {{tier}})",
"account_basics_tier_admin_suffix_with_tier": "(com tier {{tier}})",
"account_basics_title": "Conta",
"account_basics_phone_numbers_no_phone_numbers_yet": "Ainda não há números de telefone",
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome",
@@ -244,7 +244,7 @@
"account_usage_title": "Uso",
"account_basics_tier_upgrade_button": "Atualizar para Pro",
"subscribe_dialog_error_topic_already_reserved": "Tópico já reservado",
"account_basics_tier_admin_suffix_no_tier": "(sem nível)",
"account_basics_tier_admin_suffix_no_tier": "(sem tier)",
"account_basics_tier_payment_overdue": "O teu pagamento está atrasado. Por favor, atualize seu método de pagamento, ou sua conta será rebaixada em breve.",
"account_basics_tier_description": "Nível de poder da sua conta",
"account_basics_tier_free": "Grátis",
@@ -406,5 +406,59 @@
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Um usuário provisionado não pode ser editado ou apagado",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado",
"common_refresh": "Atualizar",
"email_verify_progress_title": "Verificando seu email...",
"email_verify_success_title": "E-mail verificado",
"email_verify_success_description": "Seu endereço de e-mail foi verificado e adicionado à sua conta.",
"email_verify_error_title": "A verificação falhou",
"email_verify_error_description": "Esse link de verificação está inválido ou expirou. Você pode solicitar um novo link nas configurações da sua conta.",
"email_verify_button_account": "Ir para conta",
"version_update_available_title": "Nova versão disponível",
"version_update_available_description": "O servidor do ntfy foi atualizado. Por favor, atualize a página.",
"signup_form_email": "Email (opcional, para recuperação de conta)",
"login_link_forgot_password": "Esqueci minha senha",
"reset_password_request_description": "Insira seu nome de usuário ou endereço de e-mail. Se sua conta existir, um link para redefinir sua senha será enviado pelo e-mail.",
"reset_password_request_primary_required": "Isso só funciona se você já tiver adicionado e verificado um endereço de e-mail primário.",
"reset_password_request_identifier_label": "Nome de usuário ou e-mail",
"reset_password_request_button_submit": "Enviar link de redefinição",
"reset_password_sent_title": "Verifique sua caixa de entrada",
"reset_password_sent_description": "Se uma conta existir, um link para redefinir sua senha será enviado por e-mail.",
"reset_password_back_to_login": "Voltar para login",
"reset_password_disabled": "A redefinição de senha está desativada",
"reset_password_title": "Definir nova senha",
"reset_password_form_password": "Nova senha",
"reset_password_form_confirm": "Confirmar nova senha",
"reset_password_form_button_submit": "Definir senha",
"reset_password_form_error_invalid": "Esse link de redefinição está inválido ou expirou. Por favor, peça um novo.",
"reset_password_success_title": "Senha atualizada",
"reset_password_success_description": "Sua senha mudou. Agora você pode entrar com sua nova senha.",
"action_bar_reload": "Atualizar app",
"account_basics_emails_title": "Endereços de e-mail",
"account_basics_emails_description": "Para notificações de e-mail e redefinição de senha",
"account_basics_emails_no_emails_yet": "Nenhum e-mail por enquanto",
"account_basics_emails_copied_to_clipboard": "Endereço de e-mail copiado pra área de transferência",
"account_basics_emails_chip_actions_verified": "Pode ser usado para notificações. Clique para ver ações.",
"account_basics_emails_chip_actions_unverified": "E-mail não verificado, confira sua caixa de entrada para verificar. Clique para ver ações.",
"account_basics_emails_unverified": "não verificado",
"account_basics_emails_set_primary": "Definir como e-mail primário",
"account_basics_emails_delete": "Remover e-mail",
"account_basics_emails_resend": "Reenviar e-mail de verificação",
"account_basics_emails_resent": "E-mail de verificação enviado, confira sua caixa de entrada",
"account_basics_emails_primary_elsewhere": "Esse endereço de e-mail é usado como e-mail primário em outra conta",
"account_basics_emails_no_recovery_warning": "Adicione pelo menos um endereço de e-mail para garantir que você possa recuperar sua conta caso perca sua senha.",
"account_basics_emails_no_primary_warning": "Adicione um endereço de email primário para garantir que você possa recuperar sua conta caso perca sua senha.",
"account_basics_emails_dialog_title": "Adicionar endereço de e-mail",
"account_basics_emails_dialog_description": "Insira um endereço de e-mail para adicionar à sua conta. Um link de verificação será enviado pra confirmar seu e-mail.",
"account_basics_emails_dialog_email_label": "Endereço de e-mail",
"account_basics_emails_dialog_email_placeholder": "exemplo: usuário@exemplo.com",
"account_basics_emails_dialog_verify_button": "Enviar link de verificação",
"account_basics_emails_dialog_check_inbox": "Verifique sua caixa de entrada e clique no link de verificação para confirmar esse endereço de e-mail. Ele aparecerá como não verificado até que você o verifique.",
"account_usage_emails_none": "Nenhuma notificação de e-mail pode ser enviada com essa conta",
"prefs_users_dialog_base_url_invalid": "Formato de URL inválido. Deve começar com http:// ou https://",
"prefs_users_dialog_base_url_exists": "Um usuário para esse serviço de URL já existe",
"common_close": "Fechar",
"reset_password_request_title": "Redefinir senha",
"account_basics_emails_chip_actions_primary": "E-mail primário, usado como seu endereço de e-mail padrão. Clique para ver ações.",
"account_basics_tier_provisioned": "Provisionado"
}
+2 -1
View File
@@ -405,5 +405,6 @@
"prefs_appearance_theme_dark": "Тёмная",
"prefs_appearance_theme_light": "Светлая",
"account_basics_cannot_edit_or_delete_provisioned_user": "Пользователя, созданного автоматически, нельзя изменить или удалить",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить",
"common_refresh": "Обновить"
}
+57 -3
View File
@@ -236,8 +236,8 @@
"account_basics_tier_upgrade_button": "Pro'ya yükselt",
"account_basics_tier_change_button": "Değiştir",
"account_basics_tier_paid_until": "Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek",
"account_basics_tier_admin_suffix_with_tier": "({{tier}} seviyesiyle)",
"account_basics_tier_admin_suffix_no_tier": "(seviye yok)",
"account_basics_tier_admin_suffix_with_tier": "{{tier}} seviyesiyle",
"account_basics_tier_admin_suffix_no_tier": "seviye yok",
"account_basics_tier_manage_billing_button": "Faturalandırmayı yönet",
"account_usage_reservations_title": "Ayırtılan konular",
"account_usage_reservations_none": "Bu hesap için ayırtılan konu yok",
@@ -405,5 +405,59 @@
"web_push_unknown_notification_body": "Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir",
"subscribe_dialog_subscribe_use_another_background_info": "Web uygulaması açık değilken diğer sunuculardan gelen bildirimler alınmayacaktır",
"account_basics_cannot_edit_or_delete_provisioned_user": "Yetkilendirilmiş kullanıcı düzenlenemez veya silinemez",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Sağlanmış belirteci düzenleyemez veya silemezsiniz"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Sağlanmış belirteci düzenleyemez veya silemezsiniz",
"common_close": "Kapat",
"common_refresh": "Yenile",
"email_verify_progress_title": "E-posta adresiniz doğrulanıyor...",
"email_verify_success_title": "E-posta adresi doğrulandı",
"email_verify_success_description": "E-posta adresiniz doğrulandı ve hesabınıza eklendi.",
"email_verify_error_title": "Doğrulama başarısız",
"email_verify_error_description": "Bu doğrulama bağlantısı geçersiz veya süresi doldu. Hesap ayarlarınızdan yeni bir bağlantı isteyebilirsiniz.",
"email_verify_button_account": "Hesaba git",
"version_update_available_title": "Yeni sürüm var",
"version_update_available_description": "ntfy sunucusu güncellendi. Lütfen sayfayı yenileyin.",
"signup_form_email": "E-posta adresi (isteğe bağlı, hesap kurtarma için)",
"login_link_forgot_password": "Parolayı unuttum",
"reset_password_request_title": "Parola sıfırla",
"reset_password_request_description": "Kullanıcı adınızı veya e-posta adresinizi girin. Hesabınız varsa, parolanızı sıfırlamanız için e-posta ile bir bağlantı gönderilecek.",
"reset_password_request_primary_required": "Bu, yalnızca birincil e-posta adresinizi eklediyseniz ve doğruladıysanız çalışır.",
"reset_password_request_identifier_label": "Kullanıcı adı veya e-posta adresi",
"reset_password_request_button_submit": "Sıfırlama bağlantısı gönder",
"reset_password_sent_title": "Gelen kutunuza bakın",
"reset_password_sent_description": "Hesabınız varsa, parolanızı sıfırlamanız için e-posta ile bir bağlantı gönderildi.",
"reset_password_back_to_login": "Oturum açmaya dön",
"reset_password_disabled": "Parola sıfırlama devre dışı",
"reset_password_title": "Yeni parola belirle",
"reset_password_form_password": "Yeni parola",
"reset_password_form_confirm": "Yeni parolayı onayla",
"reset_password_form_button_submit": "Parola belirle",
"reset_password_form_error_invalid": "Bu sıfırlama bağlantısı geçersiz veya süresi doldu. Lütfen yeni bir bağlantı isteyin.",
"reset_password_success_title": "Parola değiştirildi",
"reset_password_success_description": "Parolanız değiştirildi. Artık yeni parolanızla oturum açabilirsiniz.",
"action_bar_reload": "Uygulamayı yeniden yükle",
"account_basics_emails_title": "E-posta adresleri",
"account_basics_emails_description": "E-posta bildirimleri ve parola sıfırlama için",
"account_basics_emails_no_emails_yet": "Henüz e-posta yok",
"account_basics_emails_copied_to_clipboard": "E-posta adresi panoya kopyalandı",
"account_basics_emails_chip_actions_primary": "Öntanımlı e-posta adresiniz olarak kullanılan birincil adres. Eylemler için tıklayın.",
"account_basics_emails_chip_actions_verified": "Bildirimler için kullanılabilir. Eylemler için tıklayın.",
"account_basics_emails_chip_actions_unverified": "Adres doğrulanmadı, doğrulamak için gelen kutunuza bakın. Eylemler için tıklayın.",
"account_basics_emails_unverified": "doğrulanmadı",
"account_basics_emails_set_primary": "Birincil e-posta adresi olarak ayarla",
"account_basics_emails_delete": "Adresi kaldır",
"account_basics_emails_resend": "Doğrulama e-postasını yeniden gönder",
"account_basics_emails_resent": "Doğrulama e-postası gönderildi, gelen kutunuza bakın",
"account_basics_emails_primary_elsewhere": "Bu e-posta adresi başka bir hesapta birincil adres olarak kullanılıyor",
"account_basics_emails_no_recovery_warning": "Parolanızı kaybettiğinizde hesabınızı kurtarabilmek için en az bir e-posta adresi ekleyin.",
"account_basics_emails_no_primary_warning": "Parolanızı kaybettiğinizde hesabınızı kurtarabilmek için birincil e-posta adresi ekleyin.",
"account_basics_emails_dialog_title": "E-posta adresi ekle",
"account_basics_emails_dialog_description": "Hesabınıza eklemek için bir e-posta adresi girin. Adresin size ait olduğunu doğrulamak için bir doğrulama bağlantısı gönderilecek.",
"account_basics_emails_dialog_email_label": "E-posta adresi",
"account_basics_emails_dialog_email_placeholder": "örn. user@example.com",
"account_basics_emails_dialog_verify_button": "Doğrulama bağlantısı gönder",
"account_basics_emails_dialog_check_inbox": "Gelen kutunuza bakın ve bu e-posta adresini onaylamak için doğrulama bağlantısına tıklayın. Onaylayana kadar adresiniz doğrulanmadı olarak görünecek.",
"account_basics_tier_provisioned": "Yetkilendirildi",
"account_usage_emails_none": "Bu hesapla e-posta bildirimi gönderilemez",
"prefs_users_dialog_base_url_invalid": "Geçersiz URL biçimi. http:// veya https:// ile başlamalıdır",
"prefs_users_dialog_base_url_exists": "Bu hizmet URL'si için zaten bir kullanıcı var"
}
+60 -6
View File
@@ -27,14 +27,14 @@
"publish_dialog_title_topic": "Опублікувати в {{topic}}",
"publish_dialog_title_no_topic": "Опублікувати сповіщення",
"publish_dialog_progress_uploading": "Завантаження…",
"publish_dialog_message_published": "Сповіщення опубліковано",
"publish_dialog_message_published": "Сповіщення опубліковане",
"publish_dialog_attachment_limits_quota_reached": "перевищує квоту, залишилося {{remainingBytes}}",
"publish_dialog_priority_low": "Низький пріоритет",
"publish_dialog_topic_label": "Назва теми",
"publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts",
"publish_dialog_topic_reset": "Скинути тему",
"publish_dialog_title_label": "Заголовок",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостатньо місця на диску",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостачу місця на диску",
"publish_dialog_message_label": "Повідомлення",
"publish_dialog_message_placeholder": "Введіть повідомлення",
"publish_dialog_tags_label": "Теги",
@@ -276,15 +276,15 @@
"publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"",
"publish_dialog_chip_call_label": "Телефонний дзвінок",
"publish_dialog_call_reset": "Видалити телефонний дзвінок",
"account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.",
"account_basics_phone_numbers_dialog_description": "Щоб скористатися функцією сповіщень про дзвінки, вам потрібно додати та підтвердити принаймні один номер телефону. Підтвердження можна здійснити за допомогою SMS або телефонного дзвінка.",
"account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.",
"account_basics_tier_upgrade_button": "Оновлення до Pro",
"account_basics_password_description": "Зміна пароля облікового запису",
"account_usage_of_limit": "з {{limit}}",
"account_usage_unlimited": "Без обмежень",
"account_basics_tier_description": "Рівень потужності вашого облікового запису",
"account_basics_tier_admin_suffix_with_tier": "(з рівнем {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(без рівня)",
"account_basics_tier_admin_suffix_with_tier": "з рівнем {{tier}}",
"account_basics_tier_admin_suffix_no_tier": "без рівня",
"account_basics_tier_basic": "Базовий",
"account_basics_tier_free": "Безкоштовний",
"account_basics_tier_change_button": "Змінити",
@@ -406,5 +406,59 @@
"web_push_unknown_notification_body": "Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку",
"alert_notification_ios_install_required_title": "Необхідне встановлення на iOS",
"account_basics_cannot_edit_or_delete_provisioned_user": "Автоматично створеного користувача не можна редагувати чи видалити",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити",
"common_close": "Закрити",
"common_refresh": "Оновити",
"login_link_forgot_password": "Забув пароль",
"reset_password_request_title": "Скинути пароль",
"prefs_users_dialog_base_url_invalid": "Неправильний формат URL-адреси. Потрібно, щоб починалось із http:// або https://",
"email_verify_progress_title": "Підтвердження вашої електронної пошти...",
"email_verify_success_title": "Електронну пошту підтверджено",
"email_verify_success_description": "Адресу електронної пошти варифіковано та додано до вашого облікового запису.",
"email_verify_error_title": "Не вдалося перевірити",
"email_verify_error_description": "Це посилання для підтвердження невірне або його термін дії минув. Ви можете запитати нове у налаштуваннях вашого облікового запису.",
"email_verify_button_account": "Перейти до облікового запису",
"version_update_available_title": "Доступна нова версія",
"version_update_available_description": "Сервер ntfy оновлено. Будь ласка, перезавантажте сторінку.",
"signup_form_email": "Адреса електронної пошти (опціонально, для відновлення облікового запису)",
"reset_password_form_confirm": "Підтвердити новий пароль",
"reset_password_form_button_submit": "Встановити пароль",
"reset_password_form_error_invalid": "Це посилання скидання пароля невірне, або його термін дії минув. Будь ласка, запитайте нове.",
"reset_password_success_title": "Пароль змінено",
"reset_password_success_description": "Ваш пароль змінився. Ви тепер можете зайти до облікового запису із новим паролем.",
"action_bar_reload": "Перезавантажити додаток",
"account_basics_emails_title": "Електронні поштові адреси",
"account_basics_emails_description": "Для сповіщень через пошту та скидання пароля",
"prefs_users_dialog_base_url_exists": "Користувач для цієї сервісної URL-адреси вже існує",
"account_usage_emails_none": "З цього облікового запису не можна відправляти сповіщення по електронній пошті",
"account_basics_emails_dialog_title": "Додати адресу електронної пошти",
"reset_password_sent_description": "Якщо цей обліковий запис існує, посилання зі скиданням пароля буде надіслане на адресу електронної пошти.",
"reset_password_back_to_login": "Повернутися до форми входу",
"reset_password_disabled": "Скидади пароль заборонено",
"reset_password_title": "Установити новий пароль",
"reset_password_form_password": "Новий пароль",
"account_basics_emails_no_emails_yet": "Листів ще немає",
"account_basics_emails_copied_to_clipboard": "Адресу електронної пошти скопійовано до буфера обміну",
"account_basics_emails_delete": "Видалити адресу",
"account_basics_emails_dialog_email_label": "Адреса електронної пошти",
"account_basics_emails_dialog_email_placeholder": "наприклад, user@example.com",
"account_basics_emails_dialog_verify_button": "Надіслати посилання для підтвердження",
"reset_password_request_button_submit": "Надіслати посилання для скидання",
"reset_password_sent_title": "Перевірте вхідні листи",
"account_basics_emails_dialog_check_inbox": "Перевірте вхідні електронні листи та перейдіть за посиланням верифікації для підтвердження цієї адреси електронної пошти. До цього моменту вона буде у вас відображена як неперевірена.",
"account_basics_emails_primary_elsewhere": "Ця адреса електронної пошти вже використовуєтсья як основна у іншому обліковому записі",
"account_basics_emails_set_primary": "Зробити основною поштовою адресою",
"account_basics_emails_resend": "Повторно надіслати електронний лист для підтвердження",
"account_basics_emails_resent": "Листа підтвердження надіслано, перевірте свою пошту",
"account_basics_emails_no_recovery_warning": "Додайте принаймні одну адресу електронної пошти, щоб мати змогу відновити свій обліковий запис, якщо ви втратите пароль.",
"account_basics_emails_no_primary_warning": "Додайте основну адресу електронної пошти, щоб мати змогу відновити свій обліковий запис, якщо ви втратите пароль.",
"account_basics_emails_dialog_description": "Введіть адресу електронної пошти, щоб додати її до свого облікового запису. Вам буде надіслано посилання для підтвердження того, що вона ваша.",
"reset_password_request_description": "Введіть ім’я користувача або адресу електронної пошти. Якщо обліковий запис існує, на нього буде надіслано посилання для скидання пароля.",
"reset_password_request_identifier_label": "Ім’я користувача або адреса електронної пошти",
"reset_password_request_primary_required": "Це працює, лише якщо ви вже додали основну адресу електронної пошти та підтвердили її.",
"account_basics_emails_chip_actions_primary": "Основна адреса, яка використовується як ваша адреса електронної пошти за замовчуванням. Натисніть, щоб переглянути дії.",
"account_basics_emails_chip_actions_verified": "Можна використовувати для сповіщень. Натисніть, щоб переглянути дії.",
"account_basics_emails_chip_actions_unverified": "Непідтверджена адреса, перевірте свою поштову скриньку для підтвердження. Натисніть, щоб переглянути дії.",
"account_basics_emails_unverified": "неперевірена",
"account_basics_tier_provisioned": "Створений конфігурацією"
}
+7 -3
View File
@@ -6,6 +6,7 @@ import {
accountEmailVerifyUrl,
accountEmailPrimaryUrl,
accountEmailResendUrl,
accountLoginUrl,
accountPasswordResetRequestUrl,
accountPasswordResetUrl,
accountPasswordUrl,
@@ -47,8 +48,8 @@ class AccountApi {
}
async login(user) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`);
const url = accountLoginUrl(config.base_url);
console.log(`[AccountApi] Logging in at ${url}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBasicAuth({}, user.username, user.password),
@@ -57,7 +58,10 @@ class AccountApi {
if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`);
}
return json.token;
// The identifier the user typed may be a primary email; login returns the canonical username
// so callers can store it and show the real username rather than whatever was typed. Fall back
// to the typed identifier if an older server omits the username, so the session still stores one.
return { token: json.token, username: json.username || user.username };
}
async logout() {
+10 -6
View File
@@ -42,15 +42,19 @@ afterEach(() => {
});
describe("AccountApi.login", () => {
it("POSTs basic auth to the token URL and returns the token", async () => {
fetchMock.mockResolvedValue(ok({ token: "tk_returned" }));
const token = await accountApi.login({ username: "phil", password: "secret" });
it("POSTs basic auth to the login URL and returns the token and canonical username", async () => {
// The typed identifier is an email; the login endpoint returns the canonical username in one
// request, so login() surfaces it (callers store it) rather than echoing what was typed.
fetchMock.mockResolvedValue(ok({ token: "tk_returned", username: "phil" }));
const result = await accountApi.login({ username: "phil@example.com", password: "secret" });
expect(result).toEqual({ token: "tk_returned", username: "phil" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(token).toBe("tk_returned");
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/token");
expect(url).toBe("https://ntfy.sh/v1/account/login");
expect(options.method).toBe("POST");
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil@example.com:secret")}`);
});
it("throws when the server response has no token", async () => {
+1
View File
@@ -23,6 +23,7 @@ export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/aut
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const webPushUrl = (baseUrl) => `${baseUrl}/v1/webpush`;
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
export const accountLoginUrl = (baseUrl) => `${baseUrl}/v1/account/login`;
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
+5 -5
View File
@@ -24,14 +24,14 @@ const Login = () => {
event.preventDefault();
const user = { username, password };
try {
const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
await session.store(user.username, token);
const { token, username: canonicalUsername } = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, logged in as ${canonicalUsername}`);
await session.store(canonicalUsername, token);
fadeReload(routes.app);
} catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) {
setError(t("Login failed: Invalid username or password"));
setError(t("Login failed: Invalid username/email or password"));
} else {
setError(e.message);
}
@@ -53,7 +53,7 @@ const Login = () => {
required
fullWidth
id="username"
label={t("signup_form_username")}
label={t("login_form_username_label")}
name="username"
value={username}
onChange={(ev) => setUsername(ev.target.value.trim())}
+3 -3
View File
@@ -28,9 +28,9 @@ const Signup = () => {
const user = { username, password };
try {
await accountApi.create(user.username, user.password, email);
const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
await session.store(user.username, token);
const { token, username: canonicalUsername } = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, logged in as ${canonicalUsername}`);
await session.store(canonicalUsername, token);
fadeReload(routes.app);
} catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);
+2
View File
@@ -14,6 +14,8 @@ const (
subscriptionIDPrefix = "wps_"
subscriptionIDLength = 10
subscriptionEndpointLimitPerSubscriberIP = 10
schemaStore = "webpush"
)
// Errors returned by the store
+28 -58
View File
@@ -1,39 +1,11 @@
package webpush
import (
"database/sql"
"fmt"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
)
const (
postgresCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS webpush_subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at BIGINT NOT NULL,
warned_at BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);
CREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription (updated_at);
CREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription (user_id);
CREATE TABLE IF NOT EXISTS webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
);
CREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
`
postgresSelectSubscriptionIDByEndpointQuery = `SELECT id FROM webpush_subscription WHERE endpoint = $1`
postgresSelectSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM webpush_subscription WHERE subscriber_ip = $1`
postgresSelectSubscriptionsForTopicQuery = `
@@ -66,16 +38,38 @@ const (
postgresDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id NOT IN (SELECT id FROM webpush_subscription)`
)
// PostgreSQL schema management queries
// Schema version and queries
const (
pgCurrentSchemaVersion = 1
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('webpush', $1)`
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'webpush'`
postgresCurrentSchemaVersion = 1
)
var (
postgresCreateTables = schema.AsMigrateFunc(`
CREATE TABLE IF NOT EXISTS webpush_subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at BIGINT NOT NULL,
warned_at BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);
CREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription (updated_at);
CREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription (user_id);
CREATE TABLE IF NOT EXISTS webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
);
CREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic);
`)
)
// NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool.
func NewPostgresStore(d *db.DB) (*Store, error) {
if err := setupPostgres(d.Primary()); err != nil {
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, nil); err != nil {
return nil, err
}
return &Store{
@@ -97,27 +91,3 @@ func NewPostgresStore(d *db.DB) (*Store, error) {
},
}, nil
}
func setupPostgres(d *sql.DB) error {
var schemaVersion int
err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewPostgres(d)
}
if schemaVersion > pgCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion)
}
return nil
}
func setupNewPostgres(d *sql.DB) error {
return db.ExecTx(d, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
+28 -54
View File
@@ -2,39 +2,13 @@ package webpush
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
)
const (
sqliteCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
`
sqliteBuiltinStartupQueries = `
PRAGMA foreign_keys = ON;
`
@@ -71,11 +45,33 @@ const (
sqliteDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`
)
// SQLite schema management queries
// Schema version and queries
const (
sqliteCurrentSchemaVersion = 1
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
sqliteCurrentSchemaVersion = 1
)
var (
sqliteCreateTables = schema.AsMigrateFunc(`
CREATE TABLE IF NOT EXISTS subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
`)
)
// NewSQLiteStore creates a new SQLite-backed web push store.
@@ -84,7 +80,7 @@ func NewSQLiteStore(filename, startupQueries string) (*Store, error) {
if err != nil {
return nil, err
}
if err := setupSQLite(d); err != nil {
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, nil); err != nil {
return nil, err
}
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
@@ -110,28 +106,6 @@ func NewSQLiteStore(filename, startupQueries string) (*Store, error) {
}, nil
}
func setupSQLite(db *sql.DB) error {
var schemaVersion int
if err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
return setupNewSQLite(db)
} else if schemaVersion > sqliteCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
}
return nil
}
func setupNewSQLite(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
+122
View File
@@ -1,6 +1,7 @@
package webpush_test
import (
"database/sql"
"fmt"
"net/netip"
"path/filepath"
@@ -14,6 +15,127 @@ import (
const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
// Schema layout as written by ntfy releases before the db/schema framework; used to verify
// that existing databases open cleanly without an adoption step
const (
testPreFrameworkSQLiteSchema = `
CREATE TABLE subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX idx_endpoint ON subscription (endpoint);
CREATE INDEX idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX idx_topic ON subscription_topic (topic);
CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schemaVersion VALUES (1, 1);
`
testPreFrameworkPostgresSchema = `
CREATE TABLE webpush_subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at BIGINT NOT NULL,
warned_at BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);
CREATE INDEX idx_webpush_updated_at ON webpush_subscription (updated_at);
CREATE INDEX idx_webpush_user_id ON webpush_subscription (user_id);
CREATE TABLE webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
);
CREATE INDEX idx_webpush_topic ON webpush_subscription_topic (topic);
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schema_version (store, version) VALUES ('webpush', 1);
`
)
// TestStoreSchemaEquivalence verifies that a database adopted from the pre-framework layout is
// structurally identical to a freshly created one: same tables, columns, indexes and keys.
func TestStoreSchemaEquivalence(t *testing.T) {
t.Run("sqlite", func(t *testing.T) {
freshFile := filepath.Join(t.TempDir(), "fresh.db")
fresh, err := webpush.NewSQLiteStore(freshFile, "")
require.Nil(t, err)
defer fresh.Close()
migratedFile := filepath.Join(t.TempDir(), "migrated.db")
d, err := sql.Open("sqlite3", migratedFile)
require.Nil(t, err)
_, err = d.Exec(testPreFrameworkSQLiteSchema)
require.Nil(t, err)
require.Nil(t, d.Close())
migrated, err := webpush.NewSQLiteStore(migratedFile, "")
require.Nil(t, err)
defer migrated.Close()
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", migratedFile)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
})
t.Run("postgres", func(t *testing.T) {
freshDB := dbtest.CreateTestPostgres(t)
_, err := webpush.NewPostgresStore(freshDB)
require.Nil(t, err)
migratedDB := dbtest.CreateTestPostgres(t)
_, err = migratedDB.Exec(testPreFrameworkPostgresSchema)
require.Nil(t, err)
_, err = webpush.NewPostgresStore(migratedDB)
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, migratedDB))
})
}
func TestStoreSQLiteOpensExistingDatabase(t *testing.T) {
filename := filepath.Join(t.TempDir(), "webpush.db")
d, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
_, err = d.Exec(testPreFrameworkSQLiteSchema)
require.Nil(t, err)
require.Nil(t, d.Close())
store, err := webpush.NewSQLiteStore(filename, "")
require.Nil(t, err)
defer store.Close()
requireStoreUsable(t, store)
}
func TestStorePostgresOpensExistingDatabase(t *testing.T) {
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(testPreFrameworkPostgresSchema)
require.Nil(t, err)
store, err := webpush.NewPostgresStore(testDB)
require.Nil(t, err)
requireStoreUsable(t, store)
}
func requireStoreUsable(t *testing.T, store *webpush.Store) {
t.Helper()
err := store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"mytopic"})
require.Nil(t, err)
subs, err := store.SubscriptionsForTopic("mytopic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
}
func forEachBackend(t *testing.T, f func(t *testing.T, store *webpush.Store)) {
t.Run("sqlite", func(t *testing.T) {
store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "")