Compare commits

...

24 Commits

Author SHA1 Message Date
binwiederhier
ae5e1fe8d8 Merge branch 'postgres-webpush' into postgres-webpush+user 2026-02-17 20:15:27 -05:00
binwiederhier
e3a402ed95 Make user tests work for postgres and sqlite 2026-02-17 20:14:45 -05:00
binwiederhier
1abc1005d0 Re-org 2026-02-17 12:04:51 -05:00
binwiederhier
909c3fe17b Add Store-level unit tests for SQLite and PostgreSQL backends
Add shared test functions in store_test.go covering all Store interface
operations (users, tokens, access control, tiers, billing, stats, etc.)
with per-backend wrappers in store_sqlite_test.go and store_postgres_test.go
following the webpush test pattern.

Fix broken isUniqueConstraintError() which used incorrect interface
assertions instead of string matching for SQLite/PostgreSQL errors.
2026-02-16 22:56:31 -05:00
binwiederhier
07c3e280bf Refactor user package to Store interface with PostgreSQL support
Extract database operations from Manager into a Store interface with
SQLite and PostgreSQL implementations using a shared commonStore.
Split SQLite migrations into store_sqlite_migrations.go, use shared
schema_version table for PostgreSQL, rename user_user/user_tier tables
to "user"/tier, and wire up database-url in CLI commands.
2026-02-16 22:39:54 -05:00
binwiederhier
b567b4e904 Merge branch 'main' into postgres-webpush 2026-02-16 21:05:04 -05:00
binwiederhier
60fa50f0d5 Merge branch 'main' into postgres-webpush+user 2026-02-16 21:04:46 -05:00
binwiederhier
ceda5ec3d8 Move things in user package 2026-02-16 21:04:15 -05:00
binwiederhier
3d72845c81 Tests for user package 2026-02-16 20:49:17 -05:00
binwiederhier
82e15d84bd Manual changes 2026-02-16 20:02:19 -05:00
binwiederhier
4e5f95ba0c Refactor webpush store to eliminate code duplication
Consolidate SQLite and Postgres store implementations into a single
commonStore with database-specific SQL queries passed via configuration.
This eliminates ~100 lines of duplicate code while maintaining full
functionality for both backends.

Key changes:
- Move all store methods to commonStore in store.go
- Remove sqliteStore and postgresStore wrapper structs
- Refactor SQLite to use QueryRow() pattern like Postgres
- Pass database-specific queries via storeQueries struct
- Make store types unexported, only expose Store interface

All tests pass for both SQLite and PostgreSQL backends.
2026-02-16 19:53:34 -05:00
binwiederhier
869b972a50 Manual review 2026-02-16 19:12:14 -05:00
binwiederhier
bdd20197b3 Manual refinements 2026-02-16 19:06:45 -05:00
binwiederhier
a8dcecdb6d Refactor webpush store tests and add coverage
- Add SetSubscriptionUpdatedAt to Store interface, remove DB() accessor
- Rename store files to store_sqlite.go and store_postgres.go
- Use camelCase for test function names
- Add tests for upsert field updates and multi-user removal
- Use transaction in setupNewPostgresDB
- Use lowercase "excluded." in PostgreSQL upsert query
2026-02-16 18:53:12 -05:00
binwiederhier
5331437664 Unify webpush store tests across SQLite and PostgreSQL backends
Share test logic in store_test.go with thin per-backend wrappers.
Add SetSubscriptionUpdatedAt to both stores, removing the need for
raw SQL and the DB() accessor in tests.
2026-02-16 12:38:00 -05:00
binwiederhier
e432bf2886 Rename PostgreSQL table prefix from wp_ to webpush_ 2026-02-16 12:13:10 -05:00
binwiederhier
0edad84d86 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-02-16 10:55:17 -05:00
Philipp C. Heckel
ddf728acd1 Merge pull request #1607 from ZebMcKayhan/main
Added SIA-Server to integration list
2026-02-16 10:49:34 -05:00
Philipp C. Heckel
b1d3671dbb Merge pull request #1610 from luneth/patch-1
docs: Clarify F-Droid flavor excludes Google Services
2026-02-16 09:46:51 -05:00
luneth
3e6b46ec0c Fix: Clarify F-Droid flavor excludes Google Services
Updated documentation to reflect that the F-Droid flavor automatically excludes Google Services dependencies [here](33a36c4b54/app/build.gradle (L82))
2026-02-16 15:43:57 +01:00
ZebMcKayhan
b16d381626 Update integrations.md
Added SIA-Server to project list.
2026-02-15 19:41:22 +01:00
Mazurky
3bd1a1ea03 Translated using Weblate (Slovak)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sk/
2026-02-13 22:09:46 +00:00
Philipp C. Heckel
7adb37b94b Merge pull request #1603 from cyqsimon/npm-override
Allow overriding `npm` binary in Makefile
2026-02-13 16:22:50 -05:00
cyqsimon
bc08819525 Allow overriding npm binary in Makefile 2026-02-12 17:57:09 +08:00
38 changed files with 5783 additions and 3091 deletions

View File

@@ -1,4 +1,5 @@
MAKEFLAGS := --jobs=1
NPM := npm
PYTHON := python3
PIP := pip3
VERSION := $(shell git describe --tag)
@@ -137,7 +138,7 @@ web: web-deps web-build
web-build:
cd web \
&& npm run build \
&& $(NPM) run build \
&& mv build/index.html build/app.html \
&& rm -rf ../server/site \
&& mv build ../server/site \
@@ -145,20 +146,20 @@ web-build:
../server/site/config.js
web-deps:
cd web && npm install
cd web && $(NPM) install
# If this fails for .svg files, optimize them with svgo
web-deps-update:
cd web && npm update
cd web && $(NPM) update
web-fmt:
cd web && npm run format
cd web && $(NPM) run format
web-fmt-check:
cd web && npm run format:check
cd web && $(NPM) run format:check
web-lint:
cd web && npm run lint
cd web && $(NPM) run lint
# Main server/client build

View File

@@ -39,6 +39,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
@@ -143,6 +144,7 @@ func execServe(c *cli.Context) error {
keyFile := c.String("key-file")
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
databaseURL := c.String("database-url")
webPushPrivateKey := c.String("web-push-private-key")
webPushPublicKey := c.String("web-push-public-key")
webPushFile := c.String("web-push-file")
@@ -284,8 +286,8 @@ func execServe(c *cli.Context) error {
return errors.New("if set, FCM key file must exist")
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)")
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || (webPushFile == "" && databaseURL == "") || webPushEmailAddress == "" || baseURL == "") {
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file (or database-url), web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
} else if keepaliveInterval < 5*time.Second {
return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second {
@@ -494,6 +496,7 @@ func execServe(c *cli.Context) error {
conf.EnableMetrics = enableMetrics
conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP
conf.DatabaseURL = databaseURL
conf.WebPushPrivateKey = webPushPrivateKey
conf.WebPushPublicKey = webPushPublicKey
conf.WebPushFile = webPushFile

View File

@@ -29,6 +29,7 @@ var flagsUser = append(
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}),
)
var cmdUser = &cli.Command{
@@ -365,24 +366,32 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access")
if authFile == "" {
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
} else if !util.FileExists(authFile) {
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
}
databaseURL := c.String("database-url")
authDefault, err := user.ParsePermission(authDefaultAccess)
if err != nil {
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
}
authConfig := &user.Config{
Filename: authFile,
StartupQueries: authStartupQueries,
DefaultAccess: authDefault,
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
}
return user.NewManager(authConfig)
var store user.Store
if databaseURL != "" {
store, err = user.NewPostgresStore(databaseURL)
} else if authFile != "" {
if !util.FileExists(authFile) {
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
}
store, err = user.NewSQLiteStore(authFile, authStartupQueries)
} else {
return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server")
}
if err != nil {
return nil, err
}
return user.NewManager(store, authConfig)
}
func readPasswordAndConfirm(c *cli.Context) (string, error) {

View File

@@ -144,6 +144,20 @@ the message to the subscribers.
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
## PostgreSQL database
By default, ntfy uses SQLite for all database-backed stores. As an alternative, you can configure ntfy to use PostgreSQL
by setting the `database-url` option to a PostgreSQL connection string:
```yaml
database-url: "postgres://user:pass@host:5432/ntfy"
```
When `database-url` is set, ntfy will use PostgreSQL for the web push subscription store instead of SQLite. The
`web-push-file` option is not required in this case. Support for PostgreSQL for the message cache and user manager
will be added in future releases.
You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`.
## Attachments
If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable
this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`).
@@ -1141,12 +1155,15 @@ a database to keep track of the browser's subscriptions, and an admin email addr
- `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` (not required if `database-url` is set)
- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`
- `web-push-startup-queries` is an optional list of queries to run on startup`
- `web-push-expiry-warning-duration` defines the duration after which unused subscriptions are sent a warning (default is `55d`)
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
Alternatively, you can use PostgreSQL instead of SQLite for the web push subscription store by setting `database-url`
(see [PostgreSQL database](#postgresql-database)).
Limitations:
- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_
@@ -1172,9 +1189,10 @@ web-push-file: /var/cache/ntfy/webpush.db
web-push-email-address: sysadmin@example.com
```
The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 55 days,
and will automatically expire after 60 days (default). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
subscriptions are also removed automatically.
The `web-push-file` is used to store the push subscriptions in a local SQLite database. Alternatively, if `database-url`
is set, subscriptions are stored in PostgreSQL and `web-push-file` is not required. Unused subscriptions will send out
a warning after 55 days, and will automatically expire after 60 days (default). If the gateway returns an error
(e.g. 410 Gone when a user has unsubscribed), subscriptions are also removed automatically.
The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then.
@@ -1755,6 +1773,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). |
| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for database-backed stores instead of SQLite. Currently applies to the web push store. See [PostgreSQL database](#postgresql-database). |
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) |

View File

@@ -340,10 +340,6 @@ Then either follow the steps for building with or without Firebase.
Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
if you're self-hosting the server. Then run:
```
# Remove Google dependencies (FCM)
sed -i -e '/google-services/d' build.gradle
sed -i -e '/google-services/d' app/build.gradle
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
./gradlew assembleFdroidRelease
@@ -351,6 +347,8 @@ sed -i -e '/google-services/d' app/build.gradle
./gradlew bundleFdroidRelease
```
The F-Droid flavor automatically excludes Google Services dependencies.
### Build Play flavor (FCM)
!!! info
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will

View File

@@ -184,6 +184,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go)
- [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS)
- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)
- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)
## Blog + forum posts

View File

@@ -1714,3 +1714,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
* Fix crash in settings when fragment is detached during backup/restore or log operations
### ntfy server v2.12.x (UNRELEASED)
**Features:**
* Add PostgreSQL as an alternative database backend for the web push subscription store via `database-url` config option

4
go.mod
View File

@@ -30,6 +30,7 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.19.0
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.8.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/stripe/stripe-go/v74 v74.30.0
@@ -71,6 +72,9 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect

9
go.sum
View File

@@ -104,6 +104,14 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -144,6 +152,7 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=

View File

@@ -88,6 +88,7 @@ var (
// Config is the main config struct for the application. Use New to instantiate a default config struct.
type Config struct {
File string // Config file, only used for testing
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
BaseURL string
ListenHTTP string
ListenHTTPS string
@@ -192,6 +193,7 @@ type Config struct {
func NewConfig() *Config {
return &Config{
File: DefaultConfigFile, // Only used for testing
DatabaseURL: "",
BaseURL: "",
ListenHTTP: DefaultListenHTTP,
ListenHTTPS: "",

View File

@@ -37,6 +37,7 @@ import (
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig"
"heckel.io/ntfy/v2/webpush"
)
// Server is the main server, providing the UI and API for ntfy
@@ -57,7 +58,7 @@ type Server struct {
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
userManager *user.Manager // Might be nil!
messageCache *messageCache // Database that stores the messages
webPush *webPushStore // Database that stores web push subscriptions
webPush webpush.Store // Database that stores web push subscriptions
fileCache *fileCache // File system based cache that stores attachments
stripe stripeAPI // Stripe API, can be replaced with a mock
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
@@ -176,9 +177,13 @@ func New(conf *Config) (*Server, error) {
if err != nil {
return nil, err
}
var webPush *webPushStore
var wp webpush.Store
if conf.WebPushPublicKey != "" {
webPush, err = newWebPushStore(conf.WebPushFile, conf.WebPushStartupQueries)
if conf.DatabaseURL != "" {
wp, err = webpush.NewPostgresStore(conf.DatabaseURL)
} else {
wp, err = webpush.NewSQLiteStore(conf.WebPushFile, conf.WebPushStartupQueries)
}
if err != nil {
return nil, err
}
@@ -199,9 +204,10 @@ func New(conf *Config) (*Server, error) {
}
}
var userManager *user.Manager
if conf.AuthFile != "" {
if conf.AuthFile != "" || conf.DatabaseURL != "" {
authConfig := &user.Config{
Filename: conf.AuthFile,
DatabaseURL: conf.DatabaseURL,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
@@ -211,7 +217,16 @@ func New(conf *Config) (*Server, error) {
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
}
userManager, err = user.NewManager(authConfig)
var store user.Store
if conf.DatabaseURL != "" {
store, err = user.NewPostgresStore(conf.DatabaseURL)
} else {
store, err = user.NewSQLiteStore(conf.AuthFile, conf.AuthStartupQueries)
}
if err != nil {
return nil, err
}
userManager, err = user.NewManager(store, authConfig)
if err != nil {
return nil, err
}
@@ -233,7 +248,7 @@ func New(conf *Config) (*Server, error) {
s := &Server{
config: conf,
messageCache: messageCache,
webPush: webPush,
webPush: wp,
fileCache: fileCache,
firebaseClient: firebaseClient,
smtpSender: mailer,

View File

@@ -38,6 +38,12 @@
#
# firebase-key-file: <filename>
# If "database-url" is set, ntfy will use PostgreSQL for database-backed stores instead of SQLite.
# Currently this applies to the web push subscription store. Message cache and user manager support
# will be added in future releases. When set, the "web-push-file" option is not required.
#
# database-url: "postgres://user:pass@host:5432/ntfy"
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
# This allows for service restarts without losing messages in support of the since= parameter.
#

View File

@@ -12,6 +12,7 @@ import (
"github.com/SherClockHolmes/webpush-go"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
wpush "heckel.io/ntfy/v2/webpush"
)
const (
@@ -128,7 +129,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
if err != nil {
return err
}
warningSent := make([]*webPushSubscription, 0)
warningSent := make([]*wpush.Subscription, 0)
for _, subscription := range subscriptions {
if err := s.sendWebPushNotification(subscription, payload); err != nil {
log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning")
@@ -143,7 +144,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
return nil
}
func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error {
func (s *Server) sendWebPushNotification(sub *wpush.Subscription, message []byte, contexters ...log.Contexter) error {
log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message")
payload := &webpush.Subscription{
Endpoint: sub.Endpoint,

View File

@@ -5,10 +5,6 @@ package server
import (
"encoding/json"
"fmt"
"github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/http/httptest"
@@ -18,6 +14,11 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
const (
@@ -235,11 +236,11 @@ func TestServer_WebPush_Expiry(t *testing.T) {
}))
defer pushService.Close()
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
endpoint := pushService.URL + "/push-receive"
addSubscription(t, s, endpoint, "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix())
require.Nil(t, err)
require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix()))
s.pruneAndNotifyWebPushSubscriptions()
requireSubscriptionCount(t, s, "test-topic", 1)
@@ -248,8 +249,7 @@ func TestServer_WebPush_Expiry(t *testing.T) {
return received.Load()
})
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix())
require.Nil(t, err)
require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix()))
s.pruneAndNotifyWebPushSubscriptions()
waitFor(t, func() bool {

View File

@@ -593,22 +593,6 @@ func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload {
}
}
type webPushSubscription struct {
ID string
Endpoint string
Auth string
P256dh string
UserID string
}
func (w *webPushSubscription) Context() log.Context {
return map[string]any{
"web_push_subscription_id": w.ID,
"web_push_subscription_user_id": w.UserID,
"web_push_subscription_endpoint": w.Endpoint,
}
}
// https://developer.mozilla.org/en-US/docs/Web/Manifest
type webManifestResponse struct {
Name string `json:"name"`

View File

@@ -1,285 +0,0 @@
package server
import (
"database/sql"
"errors"
"heckel.io/ntfy/v2/util"
"net/netip"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
const (
subscriptionIDPrefix = "wps_"
subscriptionIDLength = 10
subscriptionEndpointLimitPerSubscriberIP = 10
)
var (
errWebPushNoRows = errors.New("no rows found")
errWebPushTooManySubscriptions = errors.New("too many subscriptions")
errWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty")
)
const (
createWebPushSubscriptionsTableQuery = `
BEGIN;
CREATE TABLE IF NOT EXISTS subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
COMMIT;
`
builtinStartupQueries = `
PRAGMA foreign_keys = ON;
`
selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?`
selectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?`
selectWebPushSubscriptionsForTopicQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM subscription_topic st
JOIN subscription s ON s.id = st.subscription_id
WHERE st.topic = ?
ORDER BY endpoint
`
selectWebPushSubscriptionsExpiringSoonQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM subscription
WHERE warned_at = 0 AND updated_at <= ?
`
insertWebPushSubscriptionQuery = `
INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (endpoint)
DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
`
updateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?`
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
deleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`
)
// Schema management queries
const (
currentWebPushSchemaVersion = 1
insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
type webPushStore struct {
db *sql.DB
}
func newWebPushStore(filename, startupQueries string) (*webPushStore, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupWebPushDB(db); err != nil {
return nil, err
}
if err := runWebPushStartupQueries(db, startupQueries); err != nil {
return nil, err
}
return &webPushStore{
db: db,
}, nil
}
func setupWebPushDB(db *sql.DB) error {
// If 'schemaVersion' table does not exist, this must be a new database
rows, err := db.Query(selectWebPushSchemaVersionQuery)
if err != nil {
return setupNewWebPushDB(db)
}
return rows.Close()
}
func setupNewWebPushDB(db *sql.DB) error {
if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil {
return err
}
return nil
}
func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
if _, err := db.Exec(builtinStartupQueries); err != nil {
return err
}
return nil
}
// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all
// existing entries for a given endpoint.
func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Read number of subscriptions for subscriber IP address
rowsCount, err := tx.Query(selectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String())
if err != nil {
return err
}
defer rowsCount.Close()
var subscriptionCount int
if !rowsCount.Next() {
return errWebPushNoRows
}
if err := rowsCount.Scan(&subscriptionCount); err != nil {
return err
}
if err := rowsCount.Close(); err != nil {
return err
}
// Read existing subscription ID for endpoint (or create new ID)
rows, err := tx.Query(selectWebPushSubscriptionIDByEndpoint, endpoint)
if err != nil {
return err
}
defer rows.Close()
var subscriptionID string
if rows.Next() {
if err := rows.Scan(&subscriptionID); err != nil {
return err
}
} else {
if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP {
return errWebPushTooManySubscriptions
}
subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
}
if err := rows.Close(); err != nil {
return err
}
// Insert or update subscription
updatedAt, warnedAt := time.Now().Unix(), 0
if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
return err
}
// Replace all subscription topics
if _, err := tx.Exec(deleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil {
return err
}
for _, topic := range topics {
if _, err = tx.Exec(insertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil {
return err
}
}
return tx.Commit()
}
// SubscriptionsForTopic returns all subscriptions for the given topic
func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscription, error) {
rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
if err != nil {
return nil, err
}
defer rows.Close()
return c.subscriptionsFromRows(rows)
}
// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period
func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPushSubscription, error) {
rows, err := c.db.Query(selectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix())
if err != nil {
return nil, err
}
defer rows.Close()
return c.subscriptionsFromRows(rows)
}
// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon
func (c *webPushStore) MarkExpiryWarningSent(subscriptions []*webPushSubscription) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, subscription := range subscriptions {
if _, err := tx.Exec(updateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil {
return err
}
}
return tx.Commit()
}
func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscription, error) {
subscriptions := make([]*webPushSubscription, 0)
for rows.Next() {
var id, endpoint, auth, p256dh, userID string
if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil {
return nil, err
}
subscriptions = append(subscriptions, &webPushSubscription{
ID: id,
Endpoint: endpoint,
Auth: auth,
P256dh: p256dh,
UserID: userID,
})
}
return subscriptions, nil
}
// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint
func (c *webPushStore) RemoveSubscriptionsByEndpoint(endpoint string) error {
_, err := c.db.Exec(deleteWebPushSubscriptionByEndpointQuery, endpoint)
return err
}
// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID
func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error {
if userID == "" {
return errWebPushUserIDCannotBeEmpty
}
_, err := c.db.Exec(deleteWebPushSubscriptionByUserIDQuery, userID)
return err
}
// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
_, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
if err != nil {
return err
}
_, err = c.db.Exec(deleteWebPushSubscriptionTopicWithoutSubscription)
return err
}
// Close closes the underlying database connection
func (c *webPushStore) Close() error {
return c.db.Close()
}

View File

@@ -1,199 +0,0 @@
package server
import (
"fmt"
"github.com/stretchr/testify/require"
"net/netip"
"path/filepath"
"testing"
"time"
)
func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
subs, err := webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
require.Equal(t, subs[0].P256dh, "p256dh-key")
require.Equal(t, subs[0].Auth, "auth-key")
require.Equal(t, subs[0].UserID, "u_1234")
subs2, err := webPush.SubscriptionsForTopic("mytopic")
require.Nil(t, err)
require.Len(t, subs2, 1)
require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint)
}
func TestWebPushStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert 10 subscriptions with the same IP address
for i := 0; i < 10; i++ {
endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i)
require.Nil(t, webPush.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
}
// Another one for the same endpoint should be fine
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
// But with a different endpoint it should fail
require.Equal(t, errWebPushTooManySubscriptions, webPush.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
// But with a different IP address it should be fine again
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"}))
}
func TestWebPushStore_UpsertSubscription_UpdateTopics(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics, and another with one topic
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 2)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint)
subs, err = webPush.SubscriptionsForTopic("topic2")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
// Update the first subscription to have only one topic
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 2)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
subs, err = webPush.SubscriptionsForTopic("topic2")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByEndpoint(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// And remove it again
require.Nil(t, webPush.RemoveSubscriptionsByEndpoint(testWebPushEndpoint))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByUserID(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// And remove it again
require.Nil(t, webPush.RemoveSubscriptionsByUserID("u_1234"))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
require.Equal(t, errWebPushUserIDCannotBeEmpty, webPush.RemoveSubscriptionsByUserID(""))
}
func TestWebPushStore_MarkExpiryWarningSent(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Mark them as warning sent
require.Nil(t, webPush.MarkExpiryWarningSent(subs))
rows, err := webPush.db.Query("SELECT endpoint FROM subscription WHERE warned_at > 0")
require.Nil(t, err)
defer rows.Close()
var endpoint string
require.True(t, rows.Next())
require.Nil(t, rows.Scan(&endpoint))
require.Nil(t, err)
require.Equal(t, testWebPushEndpoint, endpoint)
require.False(t, rows.Next())
}
func TestWebPushStore_SubscriptionsExpiring(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Fake-mark them as soon-to-expire
_, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint)
require.Nil(t, err)
// Should not be cleaned up yet
require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
// Run expiration
subs, err = webPush.SubscriptionsExpiring(7 * 24 * time.Hour)
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
}
func TestWebPushStore_RemoveExpiredSubscriptions(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Fake-mark them as expired
_, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint)
require.Nil(t, err)
// Run expiration
require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
// List again, should be 0
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func newTestWebPushStore(t *testing.T) *webPushStore {
webPush, err := newWebPushStore(filepath.Join(t.TempDir(), "webpush.db"), "")
require.Nil(t, err)
return webPush
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

986
user/store.go Normal file
View File

@@ -0,0 +1,986 @@
package user
import (
"database/sql"
"encoding/json"
"errors"
"net/netip"
"strings"
"time"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/util"
)
// Store is the interface for a user database store
type Store interface {
// User operations
UserByID(id string) (*User, error)
User(username string) (*User, error)
UserByToken(token string) (*User, error)
UserByStripeCustomer(customerID string) (*User, error)
UserIDByUsername(username string) (string, error)
Users() ([]*User, error)
UsersCount() (int64, error)
AddUser(username, hash string, role Role, provisioned bool) error
RemoveUser(username string) error
MarkUserRemoved(userID string) error
RemoveDeletedUsers() error
ChangePassword(username, hash string) error
ChangeRole(username string, role Role) error
ChangeProvisioned(username string, provisioned bool) error
ChangeSettings(userID string, prefs *Prefs) error
ChangeTier(username, tierCode string) error
ResetTier(username string) error
UpdateStats(userID string, stats *Stats) error
ResetStats() error
// Token operations
CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, provisioned bool) (*Token, error)
Token(userID, token string) (*Token, error)
Tokens(userID string) ([]*Token, error)
AllProvisionedTokens() ([]*Token, error)
ChangeTokenLabel(userID, token, label string) error
ChangeTokenExpiry(userID, token string, expires time.Time) error
UpdateTokenLastAccess(token string, lastAccess time.Time, lastOrigin netip.Addr) error
RemoveToken(userID, token string) error
RemoveExpiredTokens() error
TokenCount(userID string) (int, error)
RemoveExcessTokens(userID string, maxCount int) error
// Access operations
AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error)
AllGrants() (map[string][]Grant, error)
Grants(username string) ([]Grant, error)
AllowAccess(username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error
ResetAccess(username, topicPattern string) error
ResetAllProvisionedAccess() error
Reservations(username string) ([]Reservation, error)
HasReservation(username, topic string) (bool, error)
ReservationsCount(username string) (int64, error)
ReservationOwner(topic string) (string, error)
OtherAccessCount(username, topic string) (int, error)
// Tier operations
AddTier(tier *Tier) error
UpdateTier(tier *Tier) error
RemoveTier(code string) error
Tiers() ([]*Tier, error)
Tier(code string) (*Tier, error)
TierByStripePrice(priceID string) (*Tier, error)
// Phone operations
PhoneNumbers(userID string) ([]string, error)
AddPhoneNumber(userID, phoneNumber string) error
RemovePhoneNumber(userID, phoneNumber string) error
// Other stuff
ChangeBilling(username string, billing *Billing) error
Close() error
}
// storeQueries holds the database-specific SQL queries
type storeQueries struct {
// User queries
selectUserByID string
selectUserByName string
selectUserByToken string
selectUserByStripeID string
selectUsernames string
selectUserCount string
selectUserIDFromUsername string
insertUser string
updateUserPass string
updateUserRole string
updateUserProvisioned string
updateUserPrefs string
updateUserStats string
updateUserStatsResetAll string
updateUserTier string
updateUserDeleted string
deleteUser string
deleteUserTier string
deleteUsersMarked string
// Access queries
selectTopicPerms string
selectUserAllAccess string
selectUserAccess string
selectUserReservations string
selectUserReservationsCount string
selectUserReservationsOwner string
selectUserHasReservation string
selectOtherAccessCount string
upsertUserAccess string
deleteUserAccess string
deleteUserAccessProvisioned string
deleteTopicAccess string
deleteAllAccess string
// Token queries
selectToken string
selectTokens string
selectTokenCount string
selectAllProvisionedTokens string
upsertToken string
updateTokenLabel string
updateTokenExpiry string
updateTokenLastAccess string
deleteToken string
deleteProvisionedToken string
deleteAllToken string
deleteExpiredTokens string
deleteExcessTokens string
// Tier queries
insertTier string
selectTiers string
selectTierByCode string
selectTierByPriceID string
updateTier string
deleteTier string
// Phone queries
selectPhoneNumbers string
insertPhoneNumber string
deletePhoneNumber string
// Billing queries
updateBilling string
}
// commonStore implements store operations that work across database backends
type commonStore struct {
db *sql.DB
queries storeQueries
}
// UserByID returns the user with the given ID if it exists, or ErrUserNotFound otherwise
func (s *commonStore) UserByID(id string) (*User, error) {
rows, err := s.db.Query(s.queries.selectUserByID, id)
if err != nil {
return nil, err
}
return s.readUser(rows)
}
// User returns the user with the given username if it exists, or ErrUserNotFound otherwise
func (s *commonStore) User(username string) (*User, error) {
rows, err := s.db.Query(s.queries.selectUserByName, username)
if err != nil {
return nil, err
}
return s.readUser(rows)
}
// UserByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
func (s *commonStore) UserByToken(token string) (*User, error) {
rows, err := s.db.Query(s.queries.selectUserByToken, token, time.Now().Unix())
if err != nil {
return nil, err
}
return s.readUser(rows)
}
// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise
func (s *commonStore) UserByStripeCustomer(customerID string) (*User, error) {
rows, err := s.db.Query(s.queries.selectUserByStripeID, customerID)
if err != nil {
return nil, err
}
return s.readUser(rows)
}
// Users returns a list of users
func (s *commonStore) Users() ([]*User, error) {
rows, err := s.db.Query(s.queries.selectUsernames)
if err != nil {
return nil, err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
usernames = append(usernames, username)
}
rows.Close()
users := make([]*User, 0)
for _, username := range usernames {
user, err := s.User(username)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
}
// UsersCount returns the number of users in the database
func (s *commonStore) UsersCount() (int64, error) {
rows, err := s.db.Query(s.queries.selectUserCount)
if err != nil {
return 0, err
}
defer rows.Close()
if !rows.Next() {
return 0, errNoRows
}
var count int64
if err := rows.Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// AddUser adds a user with the given username, password hash and role
func (s *commonStore) AddUser(username, hash string, role Role, provisioned bool) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
now := time.Now().Unix()
if _, err := s.db.Exec(s.queries.insertUser, userID, username, hash, string(role), syncTopic, provisioned, now); err != nil {
if isUniqueConstraintError(err) {
return ErrUserExists
}
return err
}
return nil
}
// RemoveUser deletes the user with the given username
func (s *commonStore) RemoveUser(username string) error {
if !AllowedUsername(username) {
return ErrInvalidArgument
}
// Rows in user_access, user_token, etc. are deleted via foreign keys
if _, err := s.db.Exec(s.queries.deleteUser, username); err != nil {
return err
}
return nil
}
// MarkUserRemoved sets the deleted flag on the user, and deletes all access tokens
func (s *commonStore) MarkUserRemoved(userID string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Get username for deleteUserAccess query
user, err := s.UserByID(userID)
if err != nil {
return err
}
if _, err := tx.Exec(s.queries.deleteUserAccess, user.Name, user.Name); err != nil {
return err
}
if _, err := tx.Exec(s.queries.deleteAllToken, userID); err != nil {
return err
}
deletedTime := time.Now().Add(userHardDeleteAfterDuration).Unix()
if _, err := tx.Exec(s.queries.updateUserDeleted, deletedTime, userID); err != nil {
return err
}
return tx.Commit()
}
// RemoveDeletedUsers deletes all users that have been marked deleted
func (s *commonStore) RemoveDeletedUsers() error {
if _, err := s.db.Exec(s.queries.deleteUsersMarked, time.Now().Unix()); err != nil {
return err
}
return nil
}
// ChangePassword changes a user's password
func (s *commonStore) ChangePassword(username, hash string) error {
if _, err := s.db.Exec(s.queries.updateUserPass, hash, username); err != nil {
return err
}
return nil
}
// ChangeRole changes a user's role
func (s *commonStore) ChangeRole(username string, role Role) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(s.queries.updateUserRole, string(role), username); err != nil {
return err
}
// If changing to admin, remove all access entries
if role == RoleAdmin {
if _, err := tx.Exec(s.queries.deleteUserAccess, username, username); err != nil {
return err
}
}
return tx.Commit()
}
// ChangeProvisioned changes the provisioned status of a user
func (s *commonStore) ChangeProvisioned(username string, provisioned bool) error {
if _, err := s.db.Exec(s.queries.updateUserProvisioned, provisioned, username); err != nil {
return err
}
return nil
}
// ChangeSettings persists the user settings
func (s *commonStore) ChangeSettings(userID string, prefs *Prefs) error {
b, err := json.Marshal(prefs)
if err != nil {
return err
}
if _, err := s.db.Exec(s.queries.updateUserPrefs, string(b), userID); err != nil {
return err
}
return nil
}
// ChangeTier changes a user's tier using the tier code
func (s *commonStore) ChangeTier(username, tierCode string) error {
if _, err := s.db.Exec(s.queries.updateUserTier, tierCode, username); err != nil {
return err
}
return nil
}
// ResetTier removes the tier from the given user
func (s *commonStore) ResetTier(username string) error {
if !AllowedUsername(username) && username != Everyone && username != "" {
return ErrInvalidArgument
}
_, err := s.db.Exec(s.queries.deleteUserTier, username)
return err
}
// UpdateStats updates the user statistics
func (s *commonStore) UpdateStats(userID string, stats *Stats) error {
if _, err := s.db.Exec(s.queries.updateUserStats, stats.Messages, stats.Emails, stats.Calls, userID); err != nil {
return err
}
return nil
}
// ResetStats resets all user stats in the user database
func (s *commonStore) ResetStats() error {
if _, err := s.db.Exec(s.queries.updateUserStatsResetAll); err != nil {
return err
}
return nil
}
func (s *commonStore) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close()
var id, username, hash, role, prefs, syncTopic string
var provisioned bool
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
var messages, emails, calls int64
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
if !rows.Next() {
return nil, ErrUserNotFound
}
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
user := &User{
ID: id,
Name: username,
Hash: hash,
Role: Role(role),
Prefs: &Prefs{},
SyncTopic: syncTopic,
Provisioned: provisioned,
Stats: &Stats{
Messages: messages,
Emails: emails,
Calls: calls,
},
Billing: &Billing{
StripeCustomerID: stripeCustomerID.String,
StripeSubscriptionID: stripeSubscriptionID.String,
StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String),
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String),
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0),
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0),
},
Deleted: deleted.Valid,
}
if err := json.Unmarshal([]byte(prefs), user.Prefs); err != nil {
return nil, err
}
if tierCode.Valid {
user.Tier = &Tier{
ID: tierID.String,
Code: tierCode.String,
Name: tierName.String,
MessageLimit: messagesLimit.Int64,
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailLimit: emailsLimit.Int64,
CallLimit: callsLimit.Int64,
ReservationLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64,
StripeMonthlyPriceID: stripeMonthlyPriceID.String,
StripeYearlyPriceID: stripeYearlyPriceID.String,
}
}
return user, nil
}
// CreateToken creates a new token
func (s *commonStore) CreateToken(userID, token, label string, lastAccess time.Time, lastOrigin netip.Addr, expires time.Time, provisioned bool) (*Token, error) {
if _, err := s.db.Exec(s.queries.upsertToken, userID, token, label, lastAccess.Unix(), lastOrigin.String(), expires.Unix(), provisioned); err != nil {
return nil, err
}
return &Token{
Value: token,
Label: label,
LastAccess: lastAccess,
LastOrigin: lastOrigin,
Expires: expires,
Provisioned: provisioned,
}, nil
}
// Token returns a specific token for a user
func (s *commonStore) Token(userID, token string) (*Token, error) {
rows, err := s.db.Query(s.queries.selectToken, userID, token)
if err != nil {
return nil, err
}
defer rows.Close()
return s.readToken(rows)
}
// Tokens returns all existing tokens for the user with the given user ID
func (s *commonStore) Tokens(userID string) ([]*Token, error) {
rows, err := s.db.Query(s.queries.selectTokens, userID)
if err != nil {
return nil, err
}
defer rows.Close()
tokens := make([]*Token, 0)
for {
token, err := s.readToken(rows)
if errors.Is(err, ErrTokenNotFound) {
break
} else if err != nil {
return nil, err
}
tokens = append(tokens, token)
}
return tokens, nil
}
// AllProvisionedTokens returns all provisioned tokens
func (s *commonStore) AllProvisionedTokens() ([]*Token, error) {
rows, err := s.db.Query(s.queries.selectAllProvisionedTokens)
if err != nil {
return nil, err
}
defer rows.Close()
tokens := make([]*Token, 0)
for {
token, err := s.readToken(rows)
if errors.Is(err, ErrTokenNotFound) {
break
} else if err != nil {
return nil, err
}
tokens = append(tokens, token)
}
return tokens, nil
}
// ChangeTokenLabel updates a token's label
func (s *commonStore) ChangeTokenLabel(userID, token, label string) error {
if _, err := s.db.Exec(s.queries.updateTokenLabel, label, userID, token); err != nil {
return err
}
return nil
}
// ChangeTokenExpiry updates a token's expiry time
func (s *commonStore) ChangeTokenExpiry(userID, token string, expires time.Time) error {
if _, err := s.db.Exec(s.queries.updateTokenExpiry, expires.Unix(), userID, token); err != nil {
return err
}
return nil
}
// UpdateTokenLastAccess updates a token's last access time and origin
func (s *commonStore) UpdateTokenLastAccess(token string, lastAccess time.Time, lastOrigin netip.Addr) error {
if _, err := s.db.Exec(s.queries.updateTokenLastAccess, lastAccess.Unix(), lastOrigin.String(), token); err != nil {
return err
}
return nil
}
// RemoveToken deletes the token
func (s *commonStore) RemoveToken(userID, token string) error {
if token == "" {
return errNoTokenProvided
}
if _, err := s.db.Exec(s.queries.deleteToken, userID, token); err != nil {
return err
}
return nil
}
// RemoveExpiredTokens deletes all expired tokens from the database
func (s *commonStore) RemoveExpiredTokens() error {
if _, err := s.db.Exec(s.queries.deleteExpiredTokens, time.Now().Unix()); err != nil {
return err
}
return nil
}
// TokenCount returns the number of tokens for a user
func (s *commonStore) TokenCount(userID string) (int, error) {
rows, err := s.db.Query(s.queries.selectTokenCount, userID)
if err != nil {
return 0, err
}
defer rows.Close()
if !rows.Next() {
return 0, errNoRows
}
var count int
if err := rows.Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// RemoveExcessTokens deletes excess tokens beyond the specified maximum
func (s *commonStore) RemoveExcessTokens(userID string, maxCount int) error {
if _, err := s.db.Exec(s.queries.deleteExcessTokens, userID, userID, maxCount); err != nil {
return err
}
return nil
}
func (s *commonStore) readToken(rows *sql.Rows) (*Token, error) {
var token, label, lastOrigin string
var lastAccess, expires int64
var provisioned bool
if !rows.Next() {
return nil, ErrTokenNotFound
}
if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
lastOriginIP, err := netip.ParseAddr(lastOrigin)
if err != nil {
lastOriginIP = netip.IPv4Unspecified()
}
return &Token{
Value: token,
Label: label,
LastAccess: time.Unix(lastAccess, 0),
LastOrigin: lastOriginIP,
Expires: time.Unix(expires, 0),
Provisioned: provisioned,
}, nil
}
// AuthorizeTopicAccess returns the read/write permissions for the given username and topic.
// The found return value indicates whether an ACL entry was found at all.
func (s *commonStore) AuthorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) {
rows, err := s.db.Query(s.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)
if err != nil {
return false, false, false, err
}
defer rows.Close()
if !rows.Next() {
return false, false, false, nil
}
if err := rows.Scan(&read, &write); err != nil {
return false, false, false, err
} else if err := rows.Err(); err != nil {
return false, false, false, err
}
return read, write, true, nil
}
// AllGrants returns all user-specific access control entries, mapped to their respective user IDs
func (s *commonStore) AllGrants() (map[string][]Grant, error) {
rows, err := s.db.Query(s.queries.selectUserAllAccess)
if err != nil {
return nil, err
}
defer rows.Close()
grants := make(map[string][]Grant, 0)
for rows.Next() {
var userID, topic string
var read, write, provisioned bool
if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
if _, ok := grants[userID]; !ok {
grants[userID] = make([]Grant, 0)
}
grants[userID] = append(grants[userID], Grant{
TopicPattern: fromSQLWildcard(topic),
Permission: NewPermission(read, write),
Provisioned: provisioned,
})
}
return grants, nil
}
// Grants returns all user-specific access control entries
func (s *commonStore) Grants(username string) ([]Grant, error) {
rows, err := s.db.Query(s.queries.selectUserAccess, username)
if err != nil {
return nil, err
}
defer rows.Close()
grants := make([]Grant, 0)
for rows.Next() {
var topic string
var read, write, provisioned bool
if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
grants = append(grants, Grant{
TopicPattern: fromSQLWildcard(topic),
Permission: NewPermission(read, write),
Provisioned: provisioned,
})
}
return grants, nil
}
// AllowAccess adds or updates an entry in the access control list
func (s *commonStore) AllowAccess(username, topicPattern string, read, write bool, ownerUsername string, provisioned bool) error {
if !AllowedUsername(username) && username != Everyone {
return ErrInvalidArgument
} else if !AllowedTopicPattern(topicPattern) {
return ErrInvalidArgument
}
if _, err := s.db.Exec(s.queries.upsertUserAccess, username, toSQLWildcard(topicPattern), read, write, ownerUsername, ownerUsername, provisioned); err != nil {
return err
}
return nil
}
// ResetAccess removes an access control list entry
func (s *commonStore) ResetAccess(username, topicPattern string) error {
if !AllowedUsername(username) && username != Everyone && username != "" {
return ErrInvalidArgument
} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
return ErrInvalidArgument
}
if username == "" && topicPattern == "" {
_, err := s.db.Exec(s.queries.deleteAllAccess)
return err
} else if topicPattern == "" {
_, err := s.db.Exec(s.queries.deleteUserAccess, username, username)
return err
}
_, err := s.db.Exec(s.queries.deleteTopicAccess, username, username, toSQLWildcard(topicPattern))
return err
}
// ResetAllProvisionedAccess removes all provisioned access control entries
func (s *commonStore) ResetAllProvisionedAccess() error {
if _, err := s.db.Exec(s.queries.deleteUserAccessProvisioned); err != nil {
return err
}
return nil
}
// Reservations returns all user-owned topics, and the associated everyone-access
func (s *commonStore) Reservations(username string) ([]Reservation, error) {
rows, err := s.db.Query(s.queries.selectUserReservations, Everyone, username)
if err != nil {
return nil, err
}
defer rows.Close()
reservations := make([]Reservation, 0)
for rows.Next() {
var topic string
var ownerRead, ownerWrite bool
var everyoneRead, everyoneWrite sql.NullBool
if err := rows.Scan(&topic, &ownerRead, &ownerWrite, &everyoneRead, &everyoneWrite); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
reservations = append(reservations, Reservation{
Topic: unescapeUnderscore(topic),
Owner: NewPermission(ownerRead, ownerWrite),
Everyone: NewPermission(everyoneRead.Bool, everyoneWrite.Bool),
})
}
return reservations, nil
}
// HasReservation returns true if the given topic access is owned by the user
func (s *commonStore) HasReservation(username, topic string) (bool, error) {
rows, err := s.db.Query(s.queries.selectUserHasReservation, username, escapeUnderscore(topic))
if err != nil {
return false, err
}
defer rows.Close()
if !rows.Next() {
return false, errNoRows
}
var count int64
if err := rows.Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
// ReservationsCount returns the number of reservations owned by this user
func (s *commonStore) ReservationsCount(username string) (int64, error) {
rows, err := s.db.Query(s.queries.selectUserReservationsCount, username)
if err != nil {
return 0, err
}
defer rows.Close()
if !rows.Next() {
return 0, errNoRows
}
var count int64
if err := rows.Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// ReservationOwner returns user ID of the user that owns this topic, or an empty string if it's not owned by anyone
func (s *commonStore) ReservationOwner(topic string) (string, error) {
rows, err := s.db.Query(s.queries.selectUserReservationsOwner, escapeUnderscore(topic))
if err != nil {
return "", err
}
defer rows.Close()
if !rows.Next() {
return "", nil
}
var ownerUserID string
if err := rows.Scan(&ownerUserID); err != nil {
return "", err
}
return ownerUserID, nil
}
// OtherAccessCount returns the number of access entries for the given topic that are not owned by the user
func (s *commonStore) OtherAccessCount(username, topic string) (int, error) {
rows, err := s.db.Query(s.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username)
if err != nil {
return 0, err
}
defer rows.Close()
if !rows.Next() {
return 0, errNoRows
}
var count int
if err := rows.Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// AddTier creates a new tier in the database
func (s *commonStore) AddTier(tier *Tier) error {
if tier.ID == "" {
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
}
if _, err := s.db.Exec(s.queries.insertTier, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
return err
}
return nil
}
// UpdateTier updates a tier's properties in the database
func (s *commonStore) UpdateTier(tier *Tier) error {
if _, err := s.db.Exec(s.queries.updateTier, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
return err
}
return nil
}
// RemoveTier deletes the tier with the given code
func (s *commonStore) RemoveTier(code string) error {
if !AllowedTier(code) {
return ErrInvalidArgument
}
if _, err := s.db.Exec(s.queries.deleteTier, code); err != nil {
return err
}
return nil
}
// Tiers returns a list of all Tier structs
func (s *commonStore) Tiers() ([]*Tier, error) {
rows, err := s.db.Query(s.queries.selectTiers)
if err != nil {
return nil, err
}
defer rows.Close()
tiers := make([]*Tier, 0)
for {
tier, err := s.readTier(rows)
if errors.Is(err, ErrTierNotFound) {
break
} else if err != nil {
return nil, err
}
tiers = append(tiers, tier)
}
return tiers, nil
}
// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist
func (s *commonStore) Tier(code string) (*Tier, error) {
rows, err := s.db.Query(s.queries.selectTierByCode, code)
if err != nil {
return nil, err
}
defer rows.Close()
return s.readTier(rows)
}
// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist
func (s *commonStore) TierByStripePrice(priceID string) (*Tier, error) {
rows, err := s.db.Query(s.queries.selectTierByPriceID, priceID, priceID)
if err != nil {
return nil, err
}
defer rows.Close()
return s.readTier(rows)
}
func (s *commonStore) readTier(rows *sql.Rows) (*Tier, error) {
var id, code, name string
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
if !rows.Next() {
return nil, ErrTierNotFound
}
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
return &Tier{
ID: id,
Code: code,
Name: name,
MessageLimit: messagesLimit.Int64,
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
EmailLimit: emailsLimit.Int64,
CallLimit: callsLimit.Int64,
ReservationLimit: reservationsLimit.Int64,
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64,
StripeMonthlyPriceID: stripeMonthlyPriceID.String,
StripeYearlyPriceID: stripeYearlyPriceID.String,
}, nil
}
// PhoneNumbers returns all phone numbers for the user with the given user ID
func (s *commonStore) PhoneNumbers(userID string) ([]string, error) {
rows, err := s.db.Query(s.queries.selectPhoneNumbers, userID)
if err != nil {
return nil, err
}
defer rows.Close()
phoneNumbers := make([]string, 0)
for {
phoneNumber, err := s.readPhoneNumber(rows)
if errors.Is(err, ErrPhoneNumberNotFound) {
break
} else if err != nil {
return nil, err
}
phoneNumbers = append(phoneNumbers, phoneNumber)
}
return phoneNumbers, nil
}
// AddPhoneNumber adds a phone number to the user with the given user ID
func (s *commonStore) AddPhoneNumber(userID, phoneNumber string) error {
if _, err := s.db.Exec(s.queries.insertPhoneNumber, userID, phoneNumber); err != nil {
if isUniqueConstraintError(err) {
return ErrPhoneNumberExists
}
return err
}
return nil
}
// RemovePhoneNumber deletes a phone number from the user with the given user ID
func (s *commonStore) RemovePhoneNumber(userID, phoneNumber string) error {
_, err := s.db.Exec(s.queries.deletePhoneNumber, userID, phoneNumber)
return err
}
func (s *commonStore) readPhoneNumber(rows *sql.Rows) (string, error) {
var phoneNumber string
if !rows.Next() {
return "", ErrPhoneNumberNotFound
}
if err := rows.Scan(&phoneNumber); err != nil {
return "", err
} else if err := rows.Err(); err != nil {
return "", err
}
return phoneNumber, nil
}
// ChangeBilling updates a user's billing fields
func (s *commonStore) ChangeBilling(username string, billing *Billing) error {
if _, err := s.db.Exec(s.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil {
return err
}
return nil
}
// UserIDByUsername returns the user ID for the given username
func (s *commonStore) UserIDByUsername(username string) (string, error) {
rows, err := s.db.Query(s.queries.selectUserIDFromUsername, username)
if err != nil {
return "", err
}
defer rows.Close()
if !rows.Next() {
return "", ErrUserNotFound
}
var userID string
if err := rows.Scan(&userID); err != nil {
return "", err
}
return userID, nil
}
// Close closes the underlying database
func (s *commonStore) Close() error {
return s.db.Close()
}
// isUniqueConstraintError checks if the error is a unique constraint violation for both SQLite and PostgreSQL
func isUniqueConstraintError(err error) bool {
errStr := err.Error()
return strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "23505")
}

292
user/store_postgres.go Normal file
View File

@@ -0,0 +1,292 @@
package user
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
)
// PostgreSQL queries
const (
// User queries
postgresSelectUserByID = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = $1
`
postgresSelectUserByName = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
LEFT JOIN tier t on t.id = u.tier_id
WHERE user_name = $1
`
postgresSelectUserByToken = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = $1 AND (tk.expires = 0 OR tk.expires >= $2)
`
postgresSelectUserByStripeID = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = $1
`
postgresSelectUsernames = `
SELECT user_name
FROM "user"
ORDER BY
CASE role
WHEN 'admin' THEN 1
WHEN 'anonymous' THEN 3
ELSE 2
END, user_name
`
postgresSelectUserCount = `SELECT COUNT(*) FROM "user"`
postgresSelectUserIDFromUsername = `SELECT id FROM "user" WHERE user_name = $1`
postgresInsertUser = `INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created) VALUES ($1, $2, $3, $4, $5, $6, $7)`
postgresUpdateUserPass = `UPDATE "user" SET pass = $1 WHERE user_name = $2`
postgresUpdateUserRole = `UPDATE "user" SET role = $1 WHERE user_name = $2`
postgresUpdateUserProvisioned = `UPDATE "user" SET provisioned = $1 WHERE user_name = $2`
postgresUpdateUserPrefs = `UPDATE "user" SET prefs = $1 WHERE id = $2`
postgresUpdateUserStats = `UPDATE "user" SET stats_messages = $1, stats_emails = $2, stats_calls = $3 WHERE id = $4`
postgresUpdateUserStatsResetAll = `UPDATE "user" SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
postgresUpdateUserTier = `UPDATE "user" SET tier_id = (SELECT id FROM tier WHERE code = $1) WHERE user_name = $2`
postgresUpdateUserDeleted = `UPDATE "user" SET deleted = $1 WHERE id = $2`
postgresDeleteUser = `DELETE FROM "user" WHERE user_name = $1`
postgresDeleteUserTier = `UPDATE "user" SET tier_id = null WHERE user_name = $1`
postgresDeleteUsersMarked = `DELETE FROM "user" WHERE deleted < $1`
// Access queries
postgresSelectTopicPerms = `
SELECT read, write
FROM user_access a
JOIN "user" u ON u.id = a.user_id
WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\'
ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC
`
postgresSelectUserAllAccess = `
SELECT user_id, topic, read, write, provisioned
FROM user_access
ORDER BY LENGTH(topic) DESC, CASE WHEN write THEN 1 ELSE 0 END DESC, CASE WHEN read THEN 1 ELSE 0 END DESC, topic
`
postgresSelectUserAccess = `
SELECT topic, read, write, provisioned
FROM user_access
WHERE user_id = (SELECT id FROM "user" WHERE user_name = $1)
ORDER BY LENGTH(topic) DESC, CASE WHEN write THEN 1 ELSE 0 END DESC, CASE WHEN read THEN 1 ELSE 0 END DESC, topic
`
postgresSelectUserReservations = `
SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write
FROM user_access a_user
LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM "user" WHERE user_name = $1)
WHERE a_user.user_id = a_user.owner_user_id
AND a_user.owner_user_id = (SELECT id FROM "user" WHERE user_name = $2)
ORDER BY a_user.topic
`
postgresSelectUserReservationsCount = `
SELECT COUNT(*)
FROM user_access
WHERE user_id = owner_user_id
AND owner_user_id = (SELECT id FROM "user" WHERE user_name = $1)
`
postgresSelectUserReservationsOwner = `
SELECT owner_user_id
FROM user_access
WHERE topic = $1
AND user_id = owner_user_id
`
postgresSelectUserHasReservation = `
SELECT COUNT(*)
FROM user_access
WHERE user_id = owner_user_id
AND owner_user_id = (SELECT id FROM "user" WHERE user_name = $1)
AND topic = $2
`
postgresSelectOtherAccessCount = `
SELECT COUNT(*)
FROM user_access
WHERE (topic = $1 OR $2 LIKE topic ESCAPE '\')
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM "user" WHERE user_name = $3))
`
postgresUpsertUserAccess = `
INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)
VALUES (
(SELECT id FROM "user" WHERE user_name = $1),
$2,
$3,
$4,
CASE WHEN $5 = '' THEN NULL ELSE (SELECT id FROM "user" WHERE user_name = $6) END,
$7
)
ON CONFLICT (user_id, topic)
DO UPDATE SET read=EXCLUDED.read, write=EXCLUDED.write, owner_user_id=EXCLUDED.owner_user_id, provisioned=EXCLUDED.provisioned
`
postgresDeleteUserAccess = `
DELETE FROM user_access
WHERE user_id = (SELECT id FROM "user" WHERE user_name = $1)
OR owner_user_id = (SELECT id FROM "user" WHERE user_name = $2)
`
postgresDeleteUserAccessProvisioned = `DELETE FROM user_access WHERE provisioned = true`
postgresDeleteTopicAccess = `
DELETE FROM user_access
WHERE (user_id = (SELECT id FROM "user" WHERE user_name = $1) OR owner_user_id = (SELECT id FROM "user" WHERE user_name = $2))
AND topic = $3
`
postgresDeleteAllAccess = `DELETE FROM user_access`
// Token queries
postgresSelectToken = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1 AND token = $2`
postgresSelectTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = $1`
postgresSelectTokenCount = `SELECT COUNT(*) FROM user_token WHERE user_id = $1`
postgresSelectAllProvisionedTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = true`
postgresUpsertToken = `
INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, token)
DO UPDATE SET label = EXCLUDED.label, expires = EXCLUDED.expires, provisioned = EXCLUDED.provisioned
`
postgresUpdateTokenLabel = `UPDATE user_token SET label = $1 WHERE user_id = $2 AND token = $3`
postgresUpdateTokenExpiry = `UPDATE user_token SET expires = $1 WHERE user_id = $2 AND token = $3`
postgresUpdateTokenLastAccess = `UPDATE user_token SET last_access = $1, last_origin = $2 WHERE token = $3`
postgresDeleteToken = `DELETE FROM user_token WHERE user_id = $1 AND token = $2`
postgresDeleteProvisionedToken = `DELETE FROM user_token WHERE token = $1`
postgresDeleteAllToken = `DELETE FROM user_token WHERE user_id = $1`
postgresDeleteExpiredTokens = `DELETE FROM user_token WHERE expires > 0 AND expires < $1`
postgresDeleteExcessTokens = `
DELETE FROM user_token
WHERE user_id = $1
AND (user_id, token) NOT IN (
SELECT user_id, token
FROM user_token
WHERE user_id = $2
ORDER BY expires DESC
LIMIT $3
)
`
// Tier queries
postgresInsertTier = `
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`
postgresUpdateTier = `
UPDATE tier
SET name = $1, messages_limit = $2, messages_expiry_duration = $3, emails_limit = $4, calls_limit = $5, reservations_limit = $6, attachment_file_size_limit = $7, attachment_total_size_limit = $8, attachment_expiry_duration = $9, attachment_bandwidth_limit = $10, stripe_monthly_price_id = $11, stripe_yearly_price_id = $12
WHERE code = $13
`
postgresSelectTiers = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
`
postgresSelectTierByCode = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
WHERE code = $1
`
postgresSelectTierByPriceID = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
WHERE (stripe_monthly_price_id = $1 OR stripe_yearly_price_id = $2)
`
postgresDeleteTier = `DELETE FROM tier WHERE code = $1`
// Phone queries
postgresSelectPhoneNumbers = `SELECT phone_number FROM user_phone WHERE user_id = $1`
postgresInsertPhoneNumber = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)`
postgresDeletePhoneNumber = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
// Billing queries
postgresUpdateBilling = `
UPDATE "user"
SET stripe_customer_id = $1, stripe_subscription_id = $2, stripe_subscription_status = $3, stripe_subscription_interval = $4, stripe_subscription_paid_until = $5, stripe_subscription_cancel_at = $6
WHERE user_name = $7
`
)
// NewPostgresStore creates a new PostgreSQL-backed user store
func NewPostgresStore(dsn string) (Store, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if err := setupPostgres(db); err != nil {
return nil, err
}
return &commonStore{
db: db,
queries: storeQueries{
// User queries
selectUserByID: postgresSelectUserByID,
selectUserByName: postgresSelectUserByName,
selectUserByToken: postgresSelectUserByToken,
selectUserByStripeID: postgresSelectUserByStripeID,
selectUsernames: postgresSelectUsernames,
selectUserCount: postgresSelectUserCount,
selectUserIDFromUsername: postgresSelectUserIDFromUsername,
insertUser: postgresInsertUser,
updateUserPass: postgresUpdateUserPass,
updateUserRole: postgresUpdateUserRole,
updateUserProvisioned: postgresUpdateUserProvisioned,
updateUserPrefs: postgresUpdateUserPrefs,
updateUserStats: postgresUpdateUserStats,
updateUserStatsResetAll: postgresUpdateUserStatsResetAll,
updateUserTier: postgresUpdateUserTier,
updateUserDeleted: postgresUpdateUserDeleted,
deleteUser: postgresDeleteUser,
deleteUserTier: postgresDeleteUserTier,
deleteUsersMarked: postgresDeleteUsersMarked,
// Access queries
selectTopicPerms: postgresSelectTopicPerms,
selectUserAllAccess: postgresSelectUserAllAccess,
selectUserAccess: postgresSelectUserAccess,
selectUserReservations: postgresSelectUserReservations,
selectUserReservationsCount: postgresSelectUserReservationsCount,
selectUserReservationsOwner: postgresSelectUserReservationsOwner,
selectUserHasReservation: postgresSelectUserHasReservation,
selectOtherAccessCount: postgresSelectOtherAccessCount,
upsertUserAccess: postgresUpsertUserAccess,
deleteUserAccess: postgresDeleteUserAccess,
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisioned,
deleteTopicAccess: postgresDeleteTopicAccess,
deleteAllAccess: postgresDeleteAllAccess,
// Token queries
selectToken: postgresSelectToken,
selectTokens: postgresSelectTokens,
selectTokenCount: postgresSelectTokenCount,
selectAllProvisionedTokens: postgresSelectAllProvisionedTokens,
upsertToken: postgresUpsertToken,
updateTokenLabel: postgresUpdateTokenLabel,
updateTokenExpiry: postgresUpdateTokenExpiry,
updateTokenLastAccess: postgresUpdateTokenLastAccess,
deleteToken: postgresDeleteToken,
deleteProvisionedToken: postgresDeleteProvisionedToken,
deleteAllToken: postgresDeleteAllToken,
deleteExpiredTokens: postgresDeleteExpiredTokens,
deleteExcessTokens: postgresDeleteExcessTokens,
// Tier queries
insertTier: postgresInsertTier,
selectTiers: postgresSelectTiers,
selectTierByCode: postgresSelectTierByCode,
selectTierByPriceID: postgresSelectTierByPriceID,
updateTier: postgresUpdateTier,
deleteTier: postgresDeleteTier,
// Phone queries
selectPhoneNumbers: postgresSelectPhoneNumbers,
insertPhoneNumber: postgresInsertPhoneNumber,
deletePhoneNumber: postgresDeletePhoneNumber,
// Billing queries
updateBilling: postgresUpdateBilling,
},
}, nil
}

View File

@@ -0,0 +1,113 @@
package user
import (
"database/sql"
"fmt"
)
// Initial PostgreSQL schema
const (
postgresCreateTablesQueries = `
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit BIGINT NOT NULL,
messages_expiry_duration BIGINT NOT NULL,
emails_limit BIGINT NOT NULL,
calls_limit BIGINT NOT NULL,
reservations_limit BIGINT NOT NULL,
attachment_file_size_limit BIGINT NOT NULL,
attachment_total_size_limit BIGINT NOT NULL,
attachment_expiry_duration BIGINT NOT NULL,
attachment_bandwidth_limit BIGINT NOT NULL,
stripe_monthly_price_id TEXT,
stripe_yearly_price_id TEXT,
UNIQUE(code),
UNIQUE(stripe_monthly_price_id),
UNIQUE(stripe_yearly_price_id)
);
CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY,
tier_id TEXT REFERENCES tier(id),
user_name TEXT NOT NULL UNIQUE,
pass TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('anonymous', 'admin', 'user')),
prefs JSONB NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned BOOLEAN NOT NULL,
stats_messages BIGINT NOT NULL DEFAULT 0,
stats_emails BIGINT NOT NULL DEFAULT 0,
stats_calls BIGINT NOT NULL DEFAULT 0,
stripe_customer_id TEXT UNIQUE,
stripe_subscription_id TEXT UNIQUE,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until BIGINT,
stripe_subscription_cancel_at BIGINT,
created BIGINT NOT NULL,
deleted BIGINT
);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
topic TEXT NOT NULL,
read BOOLEAN NOT NULL,
write BOOLEAN NOT NULL,
owner_user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE,
provisioned BOOLEAN NOT NULL,
PRIMARY KEY (user_id, topic)
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
last_access BIGINT NOT NULL,
last_origin TEXT NOT NULL,
expires BIGINT NOT NULL,
provisioned BOOLEAN NOT NULL,
PRIMARY KEY (user_id, token)
);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number)
);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING;
`
)
// Schema table management queries for Postgres
const (
postgresCurrentSchemaVersion = 6
postgresSelectSchemaVersion = `SELECT version FROM schema_version WHERE store = 'user'`
postgresInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
)
func setupPostgres(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(postgresSelectSchemaVersion).Scan(&schemaVersion)
if err != nil {
return setupNewPostgres(db)
}
if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
}
// Note: PostgreSQL migrations will be added when needed
return nil
}
func setupNewPostgres(db *sql.DB) error {
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
return err
}
if _, err := db.Exec(postgresInsertSchemaVersion, postgresCurrentSchemaVersion); err != nil {
return err
}
return nil
}

208
user/store_postgres_test.go Normal file
View File

@@ -0,0 +1,208 @@
package user_test
import (
"database/sql"
"fmt"
"net/url"
"os"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
func newTestPostgresStore(t *testing.T) user.Store {
dsn := os.Getenv("NTFY_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests")
}
// Create a unique schema for this test
schema := fmt.Sprintf("test_%s", util.RandomString(10))
setupDB, err := sql.Open("pgx", dsn)
require.Nil(t, err)
_, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
require.Nil(t, err)
require.Nil(t, setupDB.Close())
// Open store with search_path set to the new schema
u, err := url.Parse(dsn)
require.Nil(t, err)
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
store, err := user.NewPostgresStore(u.String())
require.Nil(t, err)
t.Cleanup(func() {
store.Close()
cleanDB, err := sql.Open("pgx", dsn)
if err == nil {
cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
cleanDB.Close()
}
})
return store
}
func TestPostgresStoreAddUser(t *testing.T) {
testStoreAddUser(t, newTestPostgresStore(t))
}
func TestPostgresStoreAddUserAlreadyExists(t *testing.T) {
testStoreAddUserAlreadyExists(t, newTestPostgresStore(t))
}
func TestPostgresStoreRemoveUser(t *testing.T) {
testStoreRemoveUser(t, newTestPostgresStore(t))
}
func TestPostgresStoreUserByID(t *testing.T) {
testStoreUserByID(t, newTestPostgresStore(t))
}
func TestPostgresStoreUserByToken(t *testing.T) {
testStoreUserByToken(t, newTestPostgresStore(t))
}
func TestPostgresStoreUserByStripeCustomer(t *testing.T) {
testStoreUserByStripeCustomer(t, newTestPostgresStore(t))
}
func TestPostgresStoreUsers(t *testing.T) {
testStoreUsers(t, newTestPostgresStore(t))
}
func TestPostgresStoreUsersCount(t *testing.T) {
testStoreUsersCount(t, newTestPostgresStore(t))
}
func TestPostgresStoreChangePassword(t *testing.T) {
testStoreChangePassword(t, newTestPostgresStore(t))
}
func TestPostgresStoreChangeRole(t *testing.T) {
testStoreChangeRole(t, newTestPostgresStore(t))
}
func TestPostgresStoreTokens(t *testing.T) {
testStoreTokens(t, newTestPostgresStore(t))
}
func TestPostgresStoreTokenChangeLabel(t *testing.T) {
testStoreTokenChangeLabel(t, newTestPostgresStore(t))
}
func TestPostgresStoreTokenRemove(t *testing.T) {
testStoreTokenRemove(t, newTestPostgresStore(t))
}
func TestPostgresStoreTokenRemoveExpired(t *testing.T) {
testStoreTokenRemoveExpired(t, newTestPostgresStore(t))
}
func TestPostgresStoreTokenRemoveExcess(t *testing.T) {
testStoreTokenRemoveExcess(t, newTestPostgresStore(t))
}
func TestPostgresStoreTokenUpdateLastAccess(t *testing.T) {
testStoreTokenUpdateLastAccess(t, newTestPostgresStore(t))
}
func TestPostgresStoreAllowAccess(t *testing.T) {
testStoreAllowAccess(t, newTestPostgresStore(t))
}
func TestPostgresStoreAllowAccessReadOnly(t *testing.T) {
testStoreAllowAccessReadOnly(t, newTestPostgresStore(t))
}
func TestPostgresStoreResetAccess(t *testing.T) {
testStoreResetAccess(t, newTestPostgresStore(t))
}
func TestPostgresStoreResetAccessAll(t *testing.T) {
testStoreResetAccessAll(t, newTestPostgresStore(t))
}
func TestPostgresStoreAuthorizeTopicAccess(t *testing.T) {
testStoreAuthorizeTopicAccess(t, newTestPostgresStore(t))
}
func TestPostgresStoreAuthorizeTopicAccessNotFound(t *testing.T) {
testStoreAuthorizeTopicAccessNotFound(t, newTestPostgresStore(t))
}
func TestPostgresStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
testStoreAuthorizeTopicAccessDenyAll(t, newTestPostgresStore(t))
}
func TestPostgresStoreReservations(t *testing.T) {
testStoreReservations(t, newTestPostgresStore(t))
}
func TestPostgresStoreReservationsCount(t *testing.T) {
testStoreReservationsCount(t, newTestPostgresStore(t))
}
func TestPostgresStoreHasReservation(t *testing.T) {
testStoreHasReservation(t, newTestPostgresStore(t))
}
func TestPostgresStoreReservationOwner(t *testing.T) {
testStoreReservationOwner(t, newTestPostgresStore(t))
}
func TestPostgresStoreTiers(t *testing.T) {
testStoreTiers(t, newTestPostgresStore(t))
}
func TestPostgresStoreTierUpdate(t *testing.T) {
testStoreTierUpdate(t, newTestPostgresStore(t))
}
func TestPostgresStoreTierRemove(t *testing.T) {
testStoreTierRemove(t, newTestPostgresStore(t))
}
func TestPostgresStoreTierByStripePrice(t *testing.T) {
testStoreTierByStripePrice(t, newTestPostgresStore(t))
}
func TestPostgresStoreChangeTier(t *testing.T) {
testStoreChangeTier(t, newTestPostgresStore(t))
}
func TestPostgresStorePhoneNumbers(t *testing.T) {
testStorePhoneNumbers(t, newTestPostgresStore(t))
}
func TestPostgresStoreChangeSettings(t *testing.T) {
testStoreChangeSettings(t, newTestPostgresStore(t))
}
func TestPostgresStoreChangeBilling(t *testing.T) {
testStoreChangeBilling(t, newTestPostgresStore(t))
}
func TestPostgresStoreUpdateStats(t *testing.T) {
testStoreUpdateStats(t, newTestPostgresStore(t))
}
func TestPostgresStoreResetStats(t *testing.T) {
testStoreResetStats(t, newTestPostgresStore(t))
}
func TestPostgresStoreMarkUserRemoved(t *testing.T) {
testStoreMarkUserRemoved(t, newTestPostgresStore(t))
}
func TestPostgresStoreRemoveDeletedUsers(t *testing.T) {
testStoreRemoveDeletedUsers(t, newTestPostgresStore(t))
}
func TestPostgresStoreAllGrants(t *testing.T) {
testStoreAllGrants(t, newTestPostgresStore(t))
}
func TestPostgresStoreOtherAccessCount(t *testing.T) {
testStoreOtherAccessCount(t, newTestPostgresStore(t))
}

273
user/store_sqlite.go Normal file
View File

@@ -0,0 +1,273 @@
package user
import (
"database/sql"
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
const (
// User queries
sqliteSelectUserByID = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = ?
`
sqliteSelectUserByName = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ?
`
sqliteSelectUserByToken = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
`
sqliteSelectUserByStripeID = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ?
`
sqliteSelectUsernames = `
SELECT user
FROM user
ORDER BY
CASE role
WHEN 'admin' THEN 1
WHEN 'anonymous' THEN 3
ELSE 2
END, user
`
sqliteSelectUserCount = `SELECT COUNT(*) FROM user`
sqliteSelectUserIDFromUsername = `SELECT id FROM user WHERE user = ?`
sqliteInsertUser = `INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) VALUES (?, ?, ?, ?, ?, ?, ?)`
sqliteUpdateUserPass = `UPDATE user SET pass = ? WHERE user = ?`
sqliteUpdateUserRole = `UPDATE user SET role = ? WHERE user = ?`
sqliteUpdateUserProvisioned = `UPDATE user SET provisioned = ? WHERE user = ?`
sqliteUpdateUserPrefs = `UPDATE user SET prefs = ? WHERE id = ?`
sqliteUpdateUserStats = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
sqliteUpdateUserStatsResetAll = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
sqliteUpdateUserTier = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?`
sqliteUpdateUserDeleted = `UPDATE user SET deleted = ? WHERE id = ?`
sqliteDeleteUser = `DELETE FROM user WHERE user = ?`
sqliteDeleteUserTier = `UPDATE user SET tier_id = null WHERE user = ?`
sqliteDeleteUsersMarked = `DELETE FROM user WHERE deleted < ?`
// Access queries
sqliteSelectTopicPerms = `
SELECT read, write
FROM user_access a
JOIN user u ON u.id = a.user_id
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\'
ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC
`
sqliteSelectUserAllAccess = `
SELECT user_id, topic, read, write, provisioned
FROM user_access
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
`
sqliteSelectUserAccess = `
SELECT topic, read, write, provisioned
FROM user_access
WHERE user_id = (SELECT id FROM user WHERE user = ?)
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
`
sqliteSelectUserReservations = `
SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write
FROM user_access a_user
LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?)
WHERE a_user.user_id = a_user.owner_user_id
AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?)
ORDER BY a_user.topic
`
sqliteSelectUserReservationsCount = `
SELECT COUNT(*)
FROM user_access
WHERE user_id = owner_user_id
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
`
sqliteSelectUserReservationsOwner = `
SELECT owner_user_id
FROM user_access
WHERE topic = ?
AND user_id = owner_user_id
`
sqliteSelectUserHasReservation = `
SELECT COUNT(*)
FROM user_access
WHERE user_id = owner_user_id
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
AND topic = ?
`
sqliteSelectOtherAccessCount = `
SELECT COUNT(*)
FROM user_access
WHERE (topic = ? OR ? LIKE topic ESCAPE '\')
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
`
sqliteUpsertUserAccess = `
INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?)
ON CONFLICT (user_id, topic)
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned
`
sqliteDeleteUserAccess = `
DELETE FROM user_access
WHERE user_id = (SELECT id FROM user WHERE user = ?)
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
`
sqliteDeleteUserAccessProvisioned = `DELETE FROM user_access WHERE provisioned = 1`
sqliteDeleteTopicAccess = `
DELETE FROM user_access
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
AND topic = ?
`
sqliteDeleteAllAccess = `DELETE FROM user_access`
// Token queries
sqliteSelectToken = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?`
sqliteSelectTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ?`
sqliteSelectTokenCount = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
sqliteSelectAllProvisionedTokens = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1`
sqliteUpsertToken = `
INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, token)
DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned;
`
sqliteUpdateTokenLabel = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
sqliteUpdateTokenExpiry = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
sqliteUpdateTokenLastAccess = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
sqliteDeleteToken = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
sqliteDeleteProvisionedToken = `DELETE FROM user_token WHERE token = ?`
sqliteDeleteAllToken = `DELETE FROM user_token WHERE user_id = ?`
sqliteDeleteExpiredTokens = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
sqliteDeleteExcessTokens = `
DELETE FROM user_token
WHERE user_id = ?
AND (user_id, token) NOT IN (
SELECT user_id, token
FROM user_token
WHERE user_id = ?
ORDER BY expires DESC
LIMIT ?
)
`
// Tier queries
sqliteInsertTier = `
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
sqliteUpdateTier = `
UPDATE tier
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
WHERE code = ?
`
sqliteSelectTiers = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
`
sqliteSelectTierByCode = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
WHERE code = ?
`
sqliteSelectTierByPriceID = `
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
FROM tier
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
`
sqliteDeleteTier = `DELETE FROM tier WHERE code = ?`
// Phone queries
sqliteSelectPhoneNumbers = `SELECT phone_number FROM user_phone WHERE user_id = ?`
sqliteInsertPhoneNumber = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`
sqliteDeletePhoneNumber = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
// Billing queries
sqliteUpdateBilling = `
UPDATE user
SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ?
WHERE user = ?
`
)
// NewSQLiteStore creates a new SQLite-backed user store
func NewSQLiteStore(filename, startupQueries string) (Store, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupSQLite(db); err != nil {
return nil, err
}
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
return nil, err
}
return &commonStore{
db: db,
queries: storeQueries{
selectUserByID: sqliteSelectUserByID,
selectUserByName: sqliteSelectUserByName,
selectUserByToken: sqliteSelectUserByToken,
selectUserByStripeID: sqliteSelectUserByStripeID,
selectUsernames: sqliteSelectUsernames,
selectUserCount: sqliteSelectUserCount,
selectUserIDFromUsername: sqliteSelectUserIDFromUsername,
insertUser: sqliteInsertUser,
updateUserPass: sqliteUpdateUserPass,
updateUserRole: sqliteUpdateUserRole,
updateUserProvisioned: sqliteUpdateUserProvisioned,
updateUserPrefs: sqliteUpdateUserPrefs,
updateUserStats: sqliteUpdateUserStats,
updateUserStatsResetAll: sqliteUpdateUserStatsResetAll,
updateUserTier: sqliteUpdateUserTier,
updateUserDeleted: sqliteUpdateUserDeleted,
deleteUser: sqliteDeleteUser,
deleteUserTier: sqliteDeleteUserTier,
deleteUsersMarked: sqliteDeleteUsersMarked,
selectTopicPerms: sqliteSelectTopicPerms,
selectUserAllAccess: sqliteSelectUserAllAccess,
selectUserAccess: sqliteSelectUserAccess,
selectUserReservations: sqliteSelectUserReservations,
selectUserReservationsCount: sqliteSelectUserReservationsCount,
selectUserReservationsOwner: sqliteSelectUserReservationsOwner,
selectUserHasReservation: sqliteSelectUserHasReservation,
selectOtherAccessCount: sqliteSelectOtherAccessCount,
upsertUserAccess: sqliteUpsertUserAccess,
deleteUserAccess: sqliteDeleteUserAccess,
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisioned,
deleteTopicAccess: sqliteDeleteTopicAccess,
deleteAllAccess: sqliteDeleteAllAccess,
selectToken: sqliteSelectToken,
selectTokens: sqliteSelectTokens,
selectTokenCount: sqliteSelectTokenCount,
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokens,
upsertToken: sqliteUpsertToken,
updateTokenLabel: sqliteUpdateTokenLabel,
updateTokenExpiry: sqliteUpdateTokenExpiry,
updateTokenLastAccess: sqliteUpdateTokenLastAccess,
deleteToken: sqliteDeleteToken,
deleteProvisionedToken: sqliteDeleteProvisionedToken,
deleteAllToken: sqliteDeleteAllToken,
deleteExpiredTokens: sqliteDeleteExpiredTokens,
deleteExcessTokens: sqliteDeleteExcessTokens,
insertTier: sqliteInsertTier,
selectTiers: sqliteSelectTiers,
selectTierByCode: sqliteSelectTierByCode,
selectTierByPriceID: sqliteSelectTierByPriceID,
updateTier: sqliteUpdateTier,
deleteTier: sqliteDeleteTier,
selectPhoneNumbers: sqliteSelectPhoneNumbers,
insertPhoneNumber: sqliteInsertPhoneNumber,
deletePhoneNumber: sqliteDeletePhoneNumber,
updateBilling: sqliteUpdateBilling,
},
}, nil
}

483
user/store_sqlite_schema.go Normal file
View File

@@ -0,0 +1,483 @@
package user
import (
"database/sql"
"fmt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
// Initial SQLite schema
const (
sqliteCreateTablesQueries = `
BEGIN;
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL,
calls_limit INT NOT NULL,
reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL,
attachment_bandwidth_limit INT NOT NULL,
stripe_monthly_price_id TEXT,
stripe_yearly_price_id TEXT
);
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
COMMIT;
`
)
const (
sqliteBuiltinStartupQueries = `PRAGMA foreign_keys = ON;`
)
// Schema version table management for SQLite
const (
sqliteCurrentSchemaVersion = 6
sqliteInsertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
// Schema migrations for SQLite
const (
// 1 -> 2 (complex migration!)
sqliteMigrate1To2CreateTablesQueries = `
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL,
reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL,
attachment_bandwidth_limit INT NOT NULL,
stripe_price_id TEXT
);
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
CREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
`
sqliteMigrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old`
sqliteMigrate1To2InsertUserNoTx = `
INSERT INTO user (id, user, pass, role, sync_topic, created)
SELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ?
`
sqliteMigrate1To2InsertFromOldTablesAndDropNoTx = `
INSERT INTO user_access (user_id, topic, read, write)
SELECT u.id, a.topic, a.read, a.write
FROM user u
JOIN access a ON u.user = a.user;
DROP TABLE access;
DROP TABLE user_old;
`
// 2 -> 3
sqliteMigrate2To3UpdateQueries = `
ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT;
ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id;
ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT;
DROP INDEX IF EXISTS idx_tier_price_id;
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
`
// 3 -> 4
sqliteMigrate3To4UpdateQueries = `
ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
`
// 4 -> 5
sqliteMigrate4To5UpdateQueries = `
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
`
// 5 -> 6
sqliteMigrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
INSERT INTO user
SELECT
id,
tier_id,
user,
pass,
role,
prefs,
sync_topic,
0, -- provisioned
stats_messages,
stats_emails,
stats_calls,
stripe_customer_id,
stripe_subscription_id,
stripe_subscription_status,
stripe_subscription_interval,
stripe_subscription_paid_until,
stripe_subscription_cancel_at,
created,
deleted
FROM user_old;
DROP TABLE user_old;
-- Alter user_access table: Add provisioned column
ALTER TABLE user_access RENAME TO user_access_old;
CREATE TABLE user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INTEGER NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
DROP TABLE user_access_old;
-- Alter user_token table: Add provisioned column
ALTER TABLE user_token RENAME TO user_token_old;
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_token SELECT *, 0 FROM user_token_old;
DROP TABLE user_token_old;
-- Recreate indices
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
-- Re-enable foreign keys
PRAGMA foreign_keys=on;
`
)
var (
sqliteMigrations = map[int]func(db *sql.DB) error{
1: sqliteMigrateFrom1,
2: sqliteMigrateFrom2,
3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
}
)
func setupSQLite(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewSQLite(db)
}
if schemaVersion == sqliteCurrentSchemaVersion {
return nil
} else if schemaVersion > sqliteCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
}
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
fn, ok := sqliteMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func setupNewSQLite(db *sql.DB) error {
if _, err := db.Exec(sqliteCreateTablesQueries); err != nil {
return err
}
if _, err := db.Exec(sqliteInsertSchemaVersion, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil {
return err
}
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
}
return nil
}
func sqliteMigrateFrom1(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 1 to 2")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Rename user -> user_old, and create new tables
if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {
return err
}
// Insert users from user_old into new user table, with ID and sync_topic
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTx)
if err != nil {
return err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return err
}
usernames = append(usernames, username)
}
if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil {
return err
}
}
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
if _, err := tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTx); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 2); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom2(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 3); err != nil {
return err
}
return tx.Commit()
}
func sqliteMigrateFrom3(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 4); err != nil {
return err
}
return tx.Commit()
}
func sqliteMigrateFrom4(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 4 to 5")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 5); err != nil {
return err
}
return tx.Commit()
}
func sqliteMigrateFrom5(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 6); err != nil {
return err
}
return tx.Commit()
}

180
user/store_sqlite_test.go Normal file
View File

@@ -0,0 +1,180 @@
package user_test
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
)
func newTestSQLiteStore(t *testing.T) user.Store {
store, err := user.NewSQLiteStore(filepath.Join(t.TempDir(), "user.db"), "")
require.Nil(t, err)
t.Cleanup(func() { store.Close() })
return store
}
func TestSQLiteStoreAddUser(t *testing.T) {
testStoreAddUser(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreAddUserAlreadyExists(t *testing.T) {
testStoreAddUserAlreadyExists(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreRemoveUser(t *testing.T) {
testStoreRemoveUser(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUserByID(t *testing.T) {
testStoreUserByID(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUserByToken(t *testing.T) {
testStoreUserByToken(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUserByStripeCustomer(t *testing.T) {
testStoreUserByStripeCustomer(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUsers(t *testing.T) {
testStoreUsers(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUsersCount(t *testing.T) {
testStoreUsersCount(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreChangePassword(t *testing.T) {
testStoreChangePassword(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreChangeRole(t *testing.T) {
testStoreChangeRole(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTokens(t *testing.T) {
testStoreTokens(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTokenChangeLabel(t *testing.T) {
testStoreTokenChangeLabel(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTokenRemove(t *testing.T) {
testStoreTokenRemove(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTokenRemoveExpired(t *testing.T) {
testStoreTokenRemoveExpired(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTokenRemoveExcess(t *testing.T) {
testStoreTokenRemoveExcess(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTokenUpdateLastAccess(t *testing.T) {
testStoreTokenUpdateLastAccess(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreAllowAccess(t *testing.T) {
testStoreAllowAccess(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreAllowAccessReadOnly(t *testing.T) {
testStoreAllowAccessReadOnly(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreResetAccess(t *testing.T) {
testStoreResetAccess(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreResetAccessAll(t *testing.T) {
testStoreResetAccessAll(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreAuthorizeTopicAccess(t *testing.T) {
testStoreAuthorizeTopicAccess(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreAuthorizeTopicAccessNotFound(t *testing.T) {
testStoreAuthorizeTopicAccessNotFound(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
testStoreAuthorizeTopicAccessDenyAll(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreReservations(t *testing.T) {
testStoreReservations(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreReservationsCount(t *testing.T) {
testStoreReservationsCount(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreHasReservation(t *testing.T) {
testStoreHasReservation(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreReservationOwner(t *testing.T) {
testStoreReservationOwner(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTiers(t *testing.T) {
testStoreTiers(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTierUpdate(t *testing.T) {
testStoreTierUpdate(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTierRemove(t *testing.T) {
testStoreTierRemove(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreTierByStripePrice(t *testing.T) {
testStoreTierByStripePrice(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreChangeTier(t *testing.T) {
testStoreChangeTier(t, newTestSQLiteStore(t))
}
func TestSQLiteStorePhoneNumbers(t *testing.T) {
testStorePhoneNumbers(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreChangeSettings(t *testing.T) {
testStoreChangeSettings(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreChangeBilling(t *testing.T) {
testStoreChangeBilling(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUpdateStats(t *testing.T) {
testStoreUpdateStats(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreResetStats(t *testing.T) {
testStoreResetStats(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreMarkUserRemoved(t *testing.T) {
testStoreMarkUserRemoved(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreRemoveDeletedUsers(t *testing.T) {
testStoreRemoveDeletedUsers(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreAllGrants(t *testing.T) {
testStoreAllGrants(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreOtherAccessCount(t *testing.T) {
testStoreOtherAccessCount(t, newTestSQLiteStore(t))
}

619
user/store_test.go Normal file
View File

@@ -0,0 +1,619 @@
package user_test
import (
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
)
func testStoreAddUser(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
require.Equal(t, user.RoleUser, u.Role)
require.False(t, u.Provisioned)
require.NotEmpty(t, u.ID)
require.NotEmpty(t, u.SyncTopic)
}
func testStoreAddUserAlreadyExists(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Equal(t, user.ErrUserExists, store.AddUser("phil", "philhash", user.RoleUser, false))
}
func testStoreRemoveUser(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
require.Nil(t, store.RemoveUser("phil"))
_, err = store.User("phil")
require.Equal(t, user.ErrUserNotFound, err)
}
func testStoreUserByID(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleAdmin, false))
u, err := store.User("phil")
require.Nil(t, err)
u2, err := store.UserByID(u.ID)
require.Nil(t, err)
require.Equal(t, u.Name, u2.Name)
require.Equal(t, u.ID, u2.ID)
}
func testStoreUserByToken(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
tk, err := store.CreateToken(u.ID, "tk_test123", "test token", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(24*time.Hour), false)
require.Nil(t, err)
require.Equal(t, "tk_test123", tk.Value)
u2, err := store.UserByToken(tk.Value)
require.Nil(t, err)
require.Equal(t, "phil", u2.Name)
}
func testStoreUserByStripeCustomer(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.ChangeBilling("phil", &user.Billing{
StripeCustomerID: "cus_test123",
StripeSubscriptionID: "sub_test123",
}))
u, err := store.UserByStripeCustomer("cus_test123")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
require.Equal(t, "cus_test123", u.Billing.StripeCustomerID)
}
func testStoreUsers(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AddUser("ben", "benhash", user.RoleAdmin, false))
users, err := store.Users()
require.Nil(t, err)
require.True(t, len(users) >= 3) // phil, ben, and the everyone user
}
func testStoreUsersCount(t *testing.T, store user.Store) {
count, err := store.UsersCount()
require.Nil(t, err)
require.True(t, count >= 1) // At least the everyone user
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
count2, err := store.UsersCount()
require.Nil(t, err)
require.Equal(t, count+1, count2)
}
func testStoreChangePassword(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, "philhash", u.Hash)
require.Nil(t, store.ChangePassword("phil", "newhash"))
u, err = store.User("phil")
require.Nil(t, err)
require.Equal(t, "newhash", u.Hash)
}
func testStoreChangeRole(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, user.RoleUser, u.Role)
require.Nil(t, store.ChangeRole("phil", user.RoleAdmin))
u, err = store.User("phil")
require.Nil(t, err)
require.Equal(t, user.RoleAdmin, u.Role)
}
func testStoreTokens(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
now := time.Now()
expires := now.Add(24 * time.Hour)
origin := netip.MustParseAddr("9.9.9.9")
tk, err := store.CreateToken(u.ID, "tk_abc", "my token", now, origin, expires, false)
require.Nil(t, err)
require.Equal(t, "tk_abc", tk.Value)
require.Equal(t, "my token", tk.Label)
// Get single token
tk2, err := store.Token(u.ID, "tk_abc")
require.Nil(t, err)
require.Equal(t, "tk_abc", tk2.Value)
require.Equal(t, "my token", tk2.Label)
// Get all tokens
tokens, err := store.Tokens(u.ID)
require.Nil(t, err)
require.Len(t, tokens, 1)
require.Equal(t, "tk_abc", tokens[0].Value)
// Token count
count, err := store.TokenCount(u.ID)
require.Nil(t, err)
require.Equal(t, 1, count)
}
func testStoreTokenChangeLabel(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
_, err = store.CreateToken(u.ID, "tk_abc", "old label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false)
require.Nil(t, err)
require.Nil(t, store.ChangeTokenLabel(u.ID, "tk_abc", "new label"))
tk, err := store.Token(u.ID, "tk_abc")
require.Nil(t, err)
require.Equal(t, "new label", tk.Label)
}
func testStoreTokenRemove(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
_, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false)
require.Nil(t, err)
require.Nil(t, store.RemoveToken(u.ID, "tk_abc"))
_, err = store.Token(u.ID, "tk_abc")
require.Equal(t, user.ErrTokenNotFound, err)
}
func testStoreTokenRemoveExpired(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
// Create expired token and active token
_, err = store.CreateToken(u.ID, "tk_expired", "expired", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(-time.Hour), false)
require.Nil(t, err)
_, err = store.CreateToken(u.ID, "tk_active", "active", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false)
require.Nil(t, err)
require.Nil(t, store.RemoveExpiredTokens())
// Expired token should be gone
_, err = store.Token(u.ID, "tk_expired")
require.Equal(t, user.ErrTokenNotFound, err)
// Active token should still exist
tk, err := store.Token(u.ID, "tk_active")
require.Nil(t, err)
require.Equal(t, "tk_active", tk.Value)
}
func testStoreTokenRemoveExcess(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
// Create 3 tokens with increasing expiry
for i, name := range []string{"tk_a", "tk_b", "tk_c"} {
_, err = store.CreateToken(u.ID, name, name, time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Duration(i+1)*time.Hour), false)
require.Nil(t, err)
}
count, err := store.TokenCount(u.ID)
require.Nil(t, err)
require.Equal(t, 3, count)
// Remove excess, keep only 2 (the ones with latest expiry: tk_b, tk_c)
require.Nil(t, store.RemoveExcessTokens(u.ID, 2))
count, err = store.TokenCount(u.ID)
require.Nil(t, err)
require.Equal(t, 2, count)
// tk_a should be removed (earliest expiry)
_, err = store.Token(u.ID, "tk_a")
require.Equal(t, user.ErrTokenNotFound, err)
// tk_b and tk_c should remain
_, err = store.Token(u.ID, "tk_b")
require.Nil(t, err)
_, err = store.Token(u.ID, "tk_c")
require.Nil(t, err)
}
func testStoreTokenUpdateLastAccess(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
_, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), false)
require.Nil(t, err)
newTime := time.Now().Add(5 * time.Minute)
newOrigin := netip.MustParseAddr("5.5.5.5")
require.Nil(t, store.UpdateTokenLastAccess("tk_abc", newTime, newOrigin))
tk, err := store.Token(u.ID, "tk_abc")
require.Nil(t, err)
require.Equal(t, newTime.Unix(), tk.LastAccess.Unix())
require.Equal(t, newOrigin, tk.LastOrigin)
}
func testStoreAllowAccess(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false))
grants, err := store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 1)
require.Equal(t, "mytopic", grants[0].TopicPattern)
require.True(t, grants[0].Permission.IsReadWrite())
}
func testStoreAllowAccessReadOnly(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "announcements", true, false, "", false))
grants, err := store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 1)
require.True(t, grants[0].Permission.IsRead())
require.False(t, grants[0].Permission.IsWrite())
}
func testStoreResetAccess(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false))
require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false))
grants, err := store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 2)
require.Nil(t, store.ResetAccess("phil", "topic1"))
grants, err = store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 1)
require.Equal(t, "topic2", grants[0].TopicPattern)
}
func testStoreResetAccessAll(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false))
require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false))
require.Nil(t, store.ResetAccess("phil", ""))
grants, err := store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 0)
}
func testStoreAuthorizeTopicAccess(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false))
read, write, found, err := store.AuthorizeTopicAccess("phil", "mytopic")
require.Nil(t, err)
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func testStoreAuthorizeTopicAccessNotFound(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
_, _, found, err := store.AuthorizeTopicAccess("phil", "other")
require.Nil(t, err)
require.False(t, found)
}
func testStoreAuthorizeTopicAccessDenyAll(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "secret", false, false, "", false))
read, write, found, err := store.AuthorizeTopicAccess("phil", "secret")
require.Nil(t, err)
require.True(t, found)
require.False(t, read)
require.False(t, write)
}
func testStoreReservations(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false))
require.Nil(t, store.AllowAccess(user.Everyone, "mytopic", true, false, "phil", false))
reservations, err := store.Reservations("phil")
require.Nil(t, err)
require.Len(t, reservations, 1)
require.Equal(t, "mytopic", reservations[0].Topic)
require.True(t, reservations[0].Owner.IsReadWrite())
require.True(t, reservations[0].Everyone.IsRead())
require.False(t, reservations[0].Everyone.IsWrite())
}
func testStoreReservationsCount(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "phil", false))
require.Nil(t, store.AllowAccess("phil", "topic2", true, true, "phil", false))
count, err := store.ReservationsCount("phil")
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
func testStoreHasReservation(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false))
has, err := store.HasReservation("phil", "mytopic")
require.Nil(t, err)
require.True(t, has)
has, err = store.HasReservation("phil", "other")
require.Nil(t, err)
require.False(t, has)
}
func testStoreReservationOwner(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false))
owner, err := store.ReservationOwner("mytopic")
require.Nil(t, err)
require.NotEmpty(t, owner) // Returns the user ID
owner, err = store.ReservationOwner("unowned")
require.Nil(t, err)
require.Empty(t, owner)
}
func testStoreTiers(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
MessageLimit: 5000,
MessageExpiryDuration: 24 * time.Hour,
EmailLimit: 100,
CallLimit: 10,
ReservationLimit: 20,
AttachmentFileSizeLimit: 10 * 1024 * 1024,
AttachmentTotalSizeLimit: 100 * 1024 * 1024,
AttachmentExpiryDuration: 48 * time.Hour,
AttachmentBandwidthLimit: 500 * 1024 * 1024,
}
require.Nil(t, store.AddTier(tier))
// Get by code
t2, err := store.Tier("pro")
require.Nil(t, err)
require.Equal(t, "ti_test", t2.ID)
require.Equal(t, "pro", t2.Code)
require.Equal(t, "Pro", t2.Name)
require.Equal(t, int64(5000), t2.MessageLimit)
require.Equal(t, int64(100), t2.EmailLimit)
require.Equal(t, int64(10), t2.CallLimit)
require.Equal(t, int64(20), t2.ReservationLimit)
// List all tiers
tiers, err := store.Tiers()
require.Nil(t, err)
require.Len(t, tiers, 1)
require.Equal(t, "pro", tiers[0].Code)
}
func testStoreTierUpdate(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
}
require.Nil(t, store.AddTier(tier))
tier.Name = "Professional"
tier.MessageLimit = 9999
require.Nil(t, store.UpdateTier(tier))
t2, err := store.Tier("pro")
require.Nil(t, err)
require.Equal(t, "Professional", t2.Name)
require.Equal(t, int64(9999), t2.MessageLimit)
}
func testStoreTierRemove(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
}
require.Nil(t, store.AddTier(tier))
t2, err := store.Tier("pro")
require.Nil(t, err)
require.Equal(t, "pro", t2.Code)
require.Nil(t, store.RemoveTier("pro"))
_, err = store.Tier("pro")
require.Equal(t, user.ErrTierNotFound, err)
}
func testStoreTierByStripePrice(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
StripeMonthlyPriceID: "price_monthly",
StripeYearlyPriceID: "price_yearly",
}
require.Nil(t, store.AddTier(tier))
t2, err := store.TierByStripePrice("price_monthly")
require.Nil(t, err)
require.Equal(t, "pro", t2.Code)
t3, err := store.TierByStripePrice("price_yearly")
require.Nil(t, err)
require.Equal(t, "pro", t3.Code)
}
func testStoreChangeTier(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
}
require.Nil(t, store.AddTier(tier))
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.ChangeTier("phil", "pro"))
u, err := store.User("phil")
require.Nil(t, err)
require.NotNil(t, u.Tier)
require.Equal(t, "pro", u.Tier.Code)
}
func testStorePhoneNumbers(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Nil(t, store.AddPhoneNumber(u.ID, "+1234567890"))
require.Nil(t, store.AddPhoneNumber(u.ID, "+0987654321"))
numbers, err := store.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Len(t, numbers, 2)
require.Nil(t, store.RemovePhoneNumber(u.ID, "+1234567890"))
numbers, err = store.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Len(t, numbers, 1)
require.Equal(t, "+0987654321", numbers[0])
}
func testStoreChangeSettings(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
lang := "de"
prefs := &user.Prefs{Language: &lang}
require.Nil(t, store.ChangeSettings(u.ID, prefs))
u2, err := store.User("phil")
require.Nil(t, err)
require.NotNil(t, u2.Prefs)
require.Equal(t, "de", *u2.Prefs.Language)
}
func testStoreChangeBilling(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
billing := &user.Billing{
StripeCustomerID: "cus_123",
StripeSubscriptionID: "sub_456",
}
require.Nil(t, store.ChangeBilling("phil", billing))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, "cus_123", u.Billing.StripeCustomerID)
require.Equal(t, "sub_456", u.Billing.StripeSubscriptionID)
}
func testStoreUpdateStats(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
stats := &user.Stats{Messages: 42, Emails: 3, Calls: 1}
require.Nil(t, store.UpdateStats(u.ID, stats))
u2, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, int64(42), u2.Stats.Messages)
require.Equal(t, int64(3), u2.Stats.Emails)
require.Equal(t, int64(1), u2.Stats.Calls)
}
func testStoreResetStats(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Nil(t, store.UpdateStats(u.ID, &user.Stats{Messages: 42, Emails: 3, Calls: 1}))
require.Nil(t, store.ResetStats())
u2, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, int64(0), u2.Stats.Messages)
require.Equal(t, int64(0), u2.Stats.Emails)
require.Equal(t, int64(0), u2.Stats.Calls)
}
func testStoreMarkUserRemoved(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Nil(t, store.MarkUserRemoved(u.ID))
u2, err := store.User("phil")
require.Nil(t, err)
require.True(t, u2.Deleted)
}
func testStoreRemoveDeletedUsers(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Nil(t, store.MarkUserRemoved(u.ID))
// RemoveDeletedUsers only removes users past the hard-delete duration (7 days).
// Immediately after marking, the user should still exist.
require.Nil(t, store.RemoveDeletedUsers())
u2, err := store.User("phil")
require.Nil(t, err)
require.True(t, u2.Deleted)
}
func testStoreAllGrants(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false))
phil, err := store.User("phil")
require.Nil(t, err)
ben, err := store.User("ben")
require.Nil(t, err)
require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false))
require.Nil(t, store.AllowAccess("ben", "topic2", true, false, "", false))
grants, err := store.AllGrants()
require.Nil(t, err)
require.Contains(t, grants, phil.ID)
require.Contains(t, grants, ben.ID)
}
func testStoreOtherAccessCount(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("ben", "mytopic", true, true, "ben", false))
count, err := store.OtherAccessCount("phil", "mytopic")
require.Nil(t, err)
require.Equal(t, 1, count)
}

View File

@@ -242,6 +242,20 @@ const (
everyoneID = "u_everyone"
)
// Config holds the configuration for the user Manager
type Config struct {
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
DatabaseURL string // Database connection string (PostgreSQL)
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
Users []*User // Predefined users to create on startup
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
}
// Error constants used by the package
var (
ErrUnauthenticated = errors.New("unauthenticated")

View File

@@ -1,10 +1,12 @@
package user
import (
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/util"
"database/sql"
"regexp"
"strings"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/util"
)
var (
@@ -77,3 +79,69 @@ func hashPassword(password string, cost int) (string, error) {
}
return string(hash), nil
}
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
func nullInt64(v int64) sql.NullInt64 {
if v == 0 {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: v, Valid: true}
}
// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back.
func execTx(db *sql.DB, f func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := f(tx); err != nil {
return err
}
return tx.Commit()
}
// queryTx executes a function in a transaction and returns the result. If the function
// returns an error, the transaction is rolled back.
func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) {
tx, err := db.Begin()
if err != nil {
var zero T
return zero, err
}
defer tx.Rollback()
t, err := f(tx)
if err != nil {
return t, err
}
if err := tx.Commit(); err != nil {
return t, err
}
return t, nil
}
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
// and escapes '_', assuming '\' as escape character.
func toSQLWildcard(s string) string {
return escapeUnderscore(strings.ReplaceAll(s, "*", "%"))
}
// fromSQLWildcard converts a SQL wildcard string to a wildcard string. It converts '%' to '*',
// and removes the '\_' escape character.
func fromSQLWildcard(s string) string {
return strings.ReplaceAll(unescapeUnderscore(s), "%", "*")
}
func escapeUnderscore(s string) string {
return strings.ReplaceAll(s, "_", "\\_")
}
func unescapeUnderscore(s string) string {
return strings.ReplaceAll(s, "\\_", "_")
}

281
user/util_test.go Normal file
View File

@@ -0,0 +1,281 @@
package user
import (
"github.com/stretchr/testify/require"
"strings"
"testing"
)
func TestAllowedRole(t *testing.T) {
require.True(t, AllowedRole(RoleUser))
require.True(t, AllowedRole(RoleAdmin))
require.False(t, AllowedRole(RoleAnonymous))
require.False(t, AllowedRole(Role("invalid")))
require.False(t, AllowedRole(Role("")))
require.False(t, AllowedRole(Role("superadmin")))
}
func TestAllowedTopic(t *testing.T) {
// Valid topics
require.True(t, AllowedTopic("test"))
require.True(t, AllowedTopic("mytopic"))
require.True(t, AllowedTopic("topic123"))
require.True(t, AllowedTopic("my-topic"))
require.True(t, AllowedTopic("my_topic"))
require.True(t, AllowedTopic("Topic123"))
require.True(t, AllowedTopic("a"))
require.True(t, AllowedTopic(strings.Repeat("a", 64))) // Max length
// Invalid topics - wildcards not allowed
require.False(t, AllowedTopic("topic*"))
require.False(t, AllowedTopic("*"))
require.False(t, AllowedTopic("my*topic"))
// Invalid topics - special characters
require.False(t, AllowedTopic("my topic")) // Space
require.False(t, AllowedTopic("my.topic")) // Dot
require.False(t, AllowedTopic("my/topic")) // Slash
require.False(t, AllowedTopic("my@topic")) // At sign
require.False(t, AllowedTopic("my+topic")) // Plus
require.False(t, AllowedTopic("topic!")) // Exclamation
require.False(t, AllowedTopic("topic#")) // Hash
require.False(t, AllowedTopic("topic$")) // Dollar
require.False(t, AllowedTopic("topic%")) // Percent
require.False(t, AllowedTopic("topic&")) // Ampersand
require.False(t, AllowedTopic("my\\topic")) // Backslash
// Invalid topics - length
require.False(t, AllowedTopic("")) // Empty
require.False(t, AllowedTopic(strings.Repeat("a", 65))) // Too long
}
func TestAllowedTopicPattern(t *testing.T) {
// Valid patterns - same as AllowedTopic
require.True(t, AllowedTopicPattern("test"))
require.True(t, AllowedTopicPattern("mytopic"))
require.True(t, AllowedTopicPattern("topic123"))
require.True(t, AllowedTopicPattern("my-topic"))
require.True(t, AllowedTopicPattern("my_topic"))
require.True(t, AllowedTopicPattern("a"))
require.True(t, AllowedTopicPattern(strings.Repeat("a", 64))) // Max length
// Valid patterns - with wildcards
require.True(t, AllowedTopicPattern("*"))
require.True(t, AllowedTopicPattern("topic*"))
require.True(t, AllowedTopicPattern("*topic"))
require.True(t, AllowedTopicPattern("my*topic"))
require.True(t, AllowedTopicPattern("***"))
require.True(t, AllowedTopicPattern("test_*"))
require.True(t, AllowedTopicPattern("my-*-topic"))
require.True(t, AllowedTopicPattern(strings.Repeat("*", 64))) // Max length with wildcards
// Invalid patterns - special characters (other than wildcard)
require.False(t, AllowedTopicPattern("my topic")) // Space
require.False(t, AllowedTopicPattern("my.topic")) // Dot
require.False(t, AllowedTopicPattern("my/topic")) // Slash
require.False(t, AllowedTopicPattern("my@topic")) // At sign
require.False(t, AllowedTopicPattern("my+topic")) // Plus
require.False(t, AllowedTopicPattern("topic!")) // Exclamation
require.False(t, AllowedTopicPattern("topic#")) // Hash
require.False(t, AllowedTopicPattern("topic$")) // Dollar
require.False(t, AllowedTopicPattern("topic%")) // Percent
require.False(t, AllowedTopicPattern("topic&")) // Ampersand
require.False(t, AllowedTopicPattern("my\\topic")) // Backslash
// Invalid patterns - length
require.False(t, AllowedTopicPattern("")) // Empty
require.False(t, AllowedTopicPattern(strings.Repeat("a", 65))) // Too long
}
func TestValidPasswordHash(t *testing.T) {
// Valid bcrypt hashes with different versions
require.Nil(t, ValidPasswordHash("$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
require.Nil(t, ValidPasswordHash("$2b$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", 10))
require.Nil(t, ValidPasswordHash("$2y$12$1234567890123456789012u1234567890123456789012345678901", 10))
// Valid hash with minimum cost
require.Nil(t, ValidPasswordHash("$2a$04$1234567890123456789012u1234567890123456789012345678901", 4))
// Invalid - wrong prefix
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("$2c$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("$3a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("bcrypt$10$hash", 10))
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("nothash", 10))
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("", 10))
// Invalid - malformed hash
require.NotNil(t, ValidPasswordHash("$2a$10$tooshort", 10))
require.NotNil(t, ValidPasswordHash("$2a$10", 10))
require.NotNil(t, ValidPasswordHash("$2a$", 10))
// Invalid - cost too low
require.Equal(t, ErrPasswordHashWeak, ValidPasswordHash("$2a$04$1234567890123456789012u1234567890123456789012345678901", 10))
require.Equal(t, ErrPasswordHashWeak, ValidPasswordHash("$2a$09$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
// Edge case - cost exactly at minimum
require.Nil(t, ValidPasswordHash("$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
}
func TestValidToken(t *testing.T) {
// Valid tokens
require.True(t, ValidToken("tk_1234567890123456789012345678x"))
require.True(t, ValidToken("tk_abcdefghijklmnopqrstuvwxyzabc"))
require.True(t, ValidToken("tk_ABCDEFGHIJKLMNOPQRSTUVWXYZABC"))
require.True(t, ValidToken("tk_012345678901234567890123456ab"))
require.True(t, ValidToken("tk_-----------------------------"))
require.True(t, ValidToken("tk______________________________"))
// Invalid tokens - wrong prefix
require.False(t, ValidToken("tx_1234567890123456789012345678x"))
require.False(t, ValidToken("tk1234567890123456789012345678xy"))
require.False(t, ValidToken("token_1234567890123456789012345"))
// Invalid tokens - wrong length
require.False(t, ValidToken("tk_")) // Too short
require.False(t, ValidToken("tk_123")) // Too short
require.False(t, ValidToken("tk_123456789012345678901234567890")) // Too long (30 chars after prefix)
require.False(t, ValidToken("tk_123456789012345678901234567")) // Too short (28 chars)
// Invalid tokens - invalid characters
require.False(t, ValidToken("tk_123456789012345678901234567!@"))
require.False(t, ValidToken("tk_12345678901234567890123456 8x"))
require.False(t, ValidToken("tk_123456789012345678901234567.x"))
require.False(t, ValidToken("tk_123456789012345678901234567*x"))
// Invalid tokens - no prefix
require.False(t, ValidToken("1234567890123456789012345678901x"))
require.False(t, ValidToken(""))
}
func TestGenerateToken(t *testing.T) {
// Generate multiple tokens
tokens := make(map[string]bool)
for i := 0; i < 100; i++ {
token := GenerateToken()
// Check format
require.True(t, strings.HasPrefix(token, "tk_"), "Token should start with tk_")
require.Equal(t, 32, len(token), "Token should be 32 characters long")
// Check it's valid
require.True(t, ValidToken(token), "Generated token should be valid")
// Check it's lowercase
require.Equal(t, strings.ToLower(token), token, "Token should be lowercase")
// Check uniqueness
require.False(t, tokens[token], "Token should be unique")
tokens[token] = true
}
// Verify we got 100 unique tokens
require.Equal(t, 100, len(tokens))
}
func TestHashPassword(t *testing.T) {
password := "test-password-123"
// Hash the password
hash, err := HashPassword(password)
require.Nil(t, err)
require.NotEmpty(t, hash)
// Check it's a valid bcrypt hash
require.Nil(t, ValidPasswordHash(hash, DefaultUserPasswordBcryptCost))
// Check it starts with correct prefix
require.True(t, strings.HasPrefix(hash, "$2a$"))
// Hash the same password again - should produce different hash
hash2, err := HashPassword(password)
require.Nil(t, err)
require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)")
// Empty password should still work
emptyHash, err := HashPassword("")
require.Nil(t, err)
require.NotEmpty(t, emptyHash)
require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost))
}
func TestHashPassword_WithCost(t *testing.T) {
password := "test-password"
// Test with different costs
hash4, err := hashPassword(password, 4)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash4, "$2a$04$"))
hash10, err := hashPassword(password, 10)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash10, "$2a$10$"))
hash12, err := hashPassword(password, 12)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash12, "$2a$12$"))
// All should be valid
require.Nil(t, ValidPasswordHash(hash4, 4))
require.Nil(t, ValidPasswordHash(hash10, 10))
require.Nil(t, ValidPasswordHash(hash12, 12))
}
func TestUser_TierID(t *testing.T) {
// User with tier
u := &User{
Tier: &Tier{
ID: "ti_123",
Code: "pro",
},
}
require.Equal(t, "ti_123", u.TierID())
// User without tier
u2 := &User{
Tier: nil,
}
require.Equal(t, "", u2.TierID())
// Nil user
var u3 *User
require.Equal(t, "", u3.TierID())
}
func TestUser_IsAdmin(t *testing.T) {
admin := &User{Role: RoleAdmin}
require.True(t, admin.IsAdmin())
require.False(t, admin.IsUser())
user := &User{Role: RoleUser}
require.False(t, user.IsAdmin())
anonymous := &User{Role: RoleAnonymous}
require.False(t, anonymous.IsAdmin())
// Nil user
var nilUser *User
require.False(t, nilUser.IsAdmin())
}
func TestUser_IsUser(t *testing.T) {
user := &User{Role: RoleUser}
require.True(t, user.IsUser())
require.False(t, user.IsAdmin())
admin := &User{Role: RoleAdmin}
require.False(t, admin.IsUser())
anonymous := &User{Role: RoleAnonymous}
require.False(t, anonymous.IsUser())
// Nil user
var nilUser *User
require.False(t, nilUser.IsUser())
}
func TestPermission_String(t *testing.T) {
require.Equal(t, "read-write", PermissionReadWrite.String())
require.Equal(t, "read-only", PermissionRead.String())
require.Equal(t, "write-only", PermissionWrite.String())
require.Equal(t, "deny-all", PermissionDenyAll.String())
}

View File

@@ -406,5 +406,7 @@
"web_push_unknown_notification_title": "Neznáme oznámenie prijaté zo servera",
"web_push_unknown_notification_body": "Možno budete musieť aktualizovať ntfy otvorením webovej aplikácie",
"alert_notification_permission_required_title": "Oznámenia sú vypnuté",
"alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS"
"alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS",
"account_basics_cannot_edit_or_delete_provisioned_user": "Prideleného používateľa nemožno upraviť ani odstrániť",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Pridelený token nemožno upraviť ani odstrániť"
}

188
webpush/store.go Normal file
View File

@@ -0,0 +1,188 @@
package webpush
import (
"database/sql"
"errors"
"net/netip"
"time"
"heckel.io/ntfy/v2/util"
)
const (
subscriptionIDPrefix = "wps_"
subscriptionIDLength = 10
subscriptionEndpointLimitPerSubscriberIP = 10
)
// Errors returned by the store
var (
ErrWebPushTooManySubscriptions = errors.New("too many subscriptions")
ErrWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty")
)
// Store is the interface for a web push subscription store.
type Store interface {
UpsertSubscription(endpoint, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error
SubscriptionsForTopic(topic string) ([]*Subscription, error)
SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error)
MarkExpiryWarningSent(subscriptions []*Subscription) error
RemoveSubscriptionsByEndpoint(endpoint string) error
RemoveSubscriptionsByUserID(userID string) error
RemoveExpiredSubscriptions(expireAfter time.Duration) error
SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error
Close() error
}
// storeQueries holds the database-specific SQL queries.
type storeQueries struct {
selectSubscriptionIDByEndpoint string
selectSubscriptionCountBySubscriberIP string
selectSubscriptionsForTopic string
selectSubscriptionsExpiringSoon string
insertSubscription string
updateSubscriptionWarningSent string
updateSubscriptionUpdatedAt string
deleteSubscriptionByEndpoint string
deleteSubscriptionByUserID string
deleteSubscriptionByAge string
insertSubscriptionTopic string
deleteSubscriptionTopicAll string
deleteSubscriptionTopicWithoutSubscription string
}
// commonStore implements store operations that are identical across database backends.
type commonStore struct {
db *sql.DB
queries storeQueries
}
// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID.
func (s *commonStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Read number of subscriptions for subscriber IP address
var subscriptionCount int
if err := tx.QueryRow(s.queries.selectSubscriptionCountBySubscriberIP, subscriberIP.String()).Scan(&subscriptionCount); err != nil {
return err
}
// Read existing subscription ID for endpoint (or create new ID)
var subscriptionID string
err = tx.QueryRow(s.queries.selectSubscriptionIDByEndpoint, endpoint).Scan(&subscriptionID)
if errors.Is(err, sql.ErrNoRows) {
if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP {
return ErrWebPushTooManySubscriptions
}
subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
} else if err != nil {
return err
}
// Insert or update subscription
updatedAt, warnedAt := time.Now().Unix(), 0
if _, err = tx.Exec(s.queries.insertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
return err
}
// Replace all subscription topics
if _, err := tx.Exec(s.queries.deleteSubscriptionTopicAll, subscriptionID); err != nil {
return err
}
for _, topic := range topics {
if _, err = tx.Exec(s.queries.insertSubscriptionTopic, subscriptionID, topic); err != nil {
return err
}
}
return tx.Commit()
}
// SubscriptionsForTopic returns all subscriptions for the given topic.
func (s *commonStore) SubscriptionsForTopic(topic string) ([]*Subscription, error) {
rows, err := s.db.Query(s.queries.selectSubscriptionsForTopic, topic)
if err != nil {
return nil, err
}
defer rows.Close()
return subscriptionsFromRows(rows)
}
// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period.
func (s *commonStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) {
rows, err := s.db.Query(s.queries.selectSubscriptionsExpiringSoon, time.Now().Add(-warnAfter).Unix())
if err != nil {
return nil, err
}
defer rows.Close()
return subscriptionsFromRows(rows)
}
// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon.
func (s *commonStore) MarkExpiryWarningSent(subscriptions []*Subscription) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, subscription := range subscriptions {
if _, err := tx.Exec(s.queries.updateSubscriptionWarningSent, time.Now().Unix(), subscription.ID); err != nil {
return err
}
}
return tx.Commit()
}
// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint.
func (s *commonStore) RemoveSubscriptionsByEndpoint(endpoint string) error {
_, err := s.db.Exec(s.queries.deleteSubscriptionByEndpoint, endpoint)
return err
}
// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID.
func (s *commonStore) RemoveSubscriptionsByUserID(userID string) error {
if userID == "" {
return ErrWebPushUserIDCannotBeEmpty
}
_, err := s.db.Exec(s.queries.deleteSubscriptionByUserID, userID)
return err
}
// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period.
func (s *commonStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
_, err := s.db.Exec(s.queries.deleteSubscriptionByAge, time.Now().Add(-expireAfter).Unix())
if err != nil {
return err
}
_, err = s.db.Exec(s.queries.deleteSubscriptionTopicWithoutSubscription)
return err
}
// SetSubscriptionUpdatedAt updates the updated_at timestamp for a subscription by endpoint. This is
// exported for testing purposes and is not part of the Store interface.
func (s *commonStore) SetSubscriptionUpdatedAt(endpoint string, updatedAt int64) error {
_, err := s.db.Exec(s.queries.updateSubscriptionUpdatedAt, updatedAt, endpoint)
return err
}
// Close closes the underlying database connection.
func (s *commonStore) Close() error {
return s.db.Close()
}
func subscriptionsFromRows(rows *sql.Rows) ([]*Subscription, error) {
subscriptions := make([]*Subscription, 0)
for rows.Next() {
var id, endpoint, auth, p256dh, userID string
if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil {
return nil, err
}
subscriptions = append(subscriptions, &Subscription{
ID: id,
Endpoint: endpoint,
Auth: auth,
P256dh: p256dh,
UserID: userID,
})
}
return subscriptions, nil
}

130
webpush/store_postgres.go Normal file
View File

@@ -0,0 +1,130 @@
package webpush
import (
"database/sql"
"fmt"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
)
const (
pgCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS webpush_subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at BIGINT NOT NULL,
warned_at BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
);
CREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
`
pgSelectSubscriptionIDByEndpoint = `SELECT id FROM webpush_subscription WHERE endpoint = $1`
pgSelectSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM webpush_subscription WHERE subscriber_ip = $1`
pgSelectSubscriptionsForTopicQuery = `
SELECT s.id, s.endpoint, s.key_auth, s.key_p256dh, s.user_id
FROM webpush_subscription_topic st
JOIN webpush_subscription s ON s.id = st.subscription_id
WHERE st.topic = $1
ORDER BY s.endpoint
`
pgSelectSubscriptionsExpiringSoonQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM webpush_subscription
WHERE warned_at = 0 AND updated_at <= $1
`
pgInsertSubscriptionQuery = `
INSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (endpoint)
DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
`
pgUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2`
pgUpdateSubscriptionUpdatedAtQuery = `UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2`
pgDeleteSubscriptionByEndpointQuery = `DELETE FROM webpush_subscription WHERE endpoint = $1`
pgDeleteSubscriptionByUserIDQuery = `DELETE FROM webpush_subscription WHERE user_id = $1`
pgDeleteSubscriptionByAgeQuery = `DELETE FROM webpush_subscription WHERE updated_at <= $1`
pgInsertSubscriptionTopicQuery = `INSERT INTO webpush_subscription_topic (subscription_id, topic) VALUES ($1, $2)`
pgDeleteSubscriptionTopicAllQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id = $1`
pgDeleteSubscriptionTopicWithoutSubscription = `DELETE FROM webpush_subscription_topic WHERE subscription_id NOT IN (SELECT id FROM webpush_subscription)`
)
// PostgreSQL schema management queries
const (
pgCurrentSchemaVersion = 1
pgInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('webpush', $1)`
pgSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'webpush'`
)
// NewPostgresStore creates a new PostgreSQL-backed web push store.
func NewPostgresStore(dsn string) (Store, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if err := setupPostgresDB(db); err != nil {
return nil, err
}
return &commonStore{
db: db,
queries: storeQueries{
selectSubscriptionIDByEndpoint: pgSelectSubscriptionIDByEndpoint,
selectSubscriptionCountBySubscriberIP: pgSelectSubscriptionCountBySubscriberIP,
selectSubscriptionsForTopic: pgSelectSubscriptionsForTopicQuery,
selectSubscriptionsExpiringSoon: pgSelectSubscriptionsExpiringSoonQuery,
insertSubscription: pgInsertSubscriptionQuery,
updateSubscriptionWarningSent: pgUpdateSubscriptionWarningSentQuery,
updateSubscriptionUpdatedAt: pgUpdateSubscriptionUpdatedAtQuery,
deleteSubscriptionByEndpoint: pgDeleteSubscriptionByEndpointQuery,
deleteSubscriptionByUserID: pgDeleteSubscriptionByUserIDQuery,
deleteSubscriptionByAge: pgDeleteSubscriptionByAgeQuery,
insertSubscriptionTopic: pgInsertSubscriptionTopicQuery,
deleteSubscriptionTopicAll: pgDeleteSubscriptionTopicAllQuery,
deleteSubscriptionTopicWithoutSubscription: pgDeleteSubscriptionTopicWithoutSubscription,
},
}, nil
}
func setupPostgresDB(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(pgSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewPostgresDB(db)
}
if schemaVersion > pgCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion)
}
return nil
}
func setupNewPostgresDB(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(pgCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(pgInsertSchemaVersion, pgCurrentSchemaVersion); err != nil {
return err
}
return tx.Commit()
}

View File

@@ -0,0 +1,91 @@
package webpush_test
import (
"database/sql"
"fmt"
"net/url"
"os"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/webpush"
)
func newTestPostgresStore(t *testing.T) webpush.Store {
dsn := os.Getenv("NTFY_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests")
}
// Create a unique schema for this test
schema := fmt.Sprintf("test_%s", util.RandomString(10))
setupDB, err := sql.Open("pgx", dsn)
require.Nil(t, err)
_, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
require.Nil(t, err)
require.Nil(t, setupDB.Close())
// Open store with search_path set to the new schema
u, err := url.Parse(dsn)
require.Nil(t, err)
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
store, err := webpush.NewPostgresStore(u.String())
require.Nil(t, err)
t.Cleanup(func() {
store.Close()
cleanDB, err := sql.Open("pgx", dsn)
if err == nil {
cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
cleanDB.Close()
}
})
return store
}
func TestPostgresStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) {
testStoreUpsertSubscriptionSubscriptionsForTopic(t, newTestPostgresStore(t))
}
func TestPostgresStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) {
testStoreUpsertSubscriptionSubscriberIPLimitReached(t, newTestPostgresStore(t))
}
func TestPostgresStoreUpsertSubscriptionUpdateTopics(t *testing.T) {
testStoreUpsertSubscriptionUpdateTopics(t, newTestPostgresStore(t))
}
func TestPostgresStoreUpsertSubscriptionUpdateFields(t *testing.T) {
testStoreUpsertSubscriptionUpdateFields(t, newTestPostgresStore(t))
}
func TestPostgresStoreRemoveByUserIDMultiple(t *testing.T) {
testStoreRemoveByUserIDMultiple(t, newTestPostgresStore(t))
}
func TestPostgresStoreRemoveByEndpoint(t *testing.T) {
testStoreRemoveByEndpoint(t, newTestPostgresStore(t))
}
func TestPostgresStoreRemoveByUserID(t *testing.T) {
testStoreRemoveByUserID(t, newTestPostgresStore(t))
}
func TestPostgresStoreRemoveByUserIDEmpty(t *testing.T) {
testStoreRemoveByUserIDEmpty(t, newTestPostgresStore(t))
}
func TestPostgresStoreExpiryWarningSent(t *testing.T) {
store := newTestPostgresStore(t)
testStoreExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt)
}
func TestPostgresStoreExpiring(t *testing.T) {
store := newTestPostgresStore(t)
testStoreExpiring(t, store, store.SetSubscriptionUpdatedAt)
}
func TestPostgresStoreRemoveExpired(t *testing.T) {
store := newTestPostgresStore(t)
testStoreRemoveExpired(t, store, store.SetSubscriptionUpdatedAt)
}

142
webpush/store_sqlite.go Normal file
View File

@@ -0,0 +1,142 @@
package webpush
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
const (
sqliteCreateWebPushSubscriptionsTableQuery = `
BEGIN;
CREATE TABLE IF NOT EXISTS subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
COMMIT;
`
sqliteBuiltinStartupQueries = `
PRAGMA foreign_keys = ON;
`
sqliteSelectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?`
sqliteSelectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?`
sqliteSelectWebPushSubscriptionsForTopicQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM subscription_topic st
JOIN subscription s ON s.id = st.subscription_id
WHERE st.topic = ?
ORDER BY endpoint
`
sqliteSelectWebPushSubscriptionsExpiringSoonQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM subscription
WHERE warned_at = 0 AND updated_at <= ?
`
sqliteInsertWebPushSubscriptionQuery = `
INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (endpoint)
DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
`
sqliteUpdateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
sqliteUpdateWebPushSubscriptionUpdatedAtQuery = `UPDATE subscription SET updated_at = ? WHERE endpoint = ?`
sqliteDeleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?`
sqliteDeleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
sqliteDeleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
sqliteInsertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
sqliteDeleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
sqliteDeleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`
)
// SQLite schema management queries
const (
sqliteCurrentWebPushSchemaVersion = 1
sqliteInsertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteSelectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
// NewSQLiteStore creates a new SQLite-backed web push store.
func NewSQLiteStore(filename, startupQueries string) (Store, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupSQLite(db); err != nil {
return nil, err
}
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
return nil, err
}
return &commonStore{
db: db,
queries: storeQueries{
selectSubscriptionIDByEndpoint: sqliteSelectWebPushSubscriptionIDByEndpoint,
selectSubscriptionCountBySubscriberIP: sqliteSelectWebPushSubscriptionCountBySubscriberIP,
selectSubscriptionsForTopic: sqliteSelectWebPushSubscriptionsForTopicQuery,
selectSubscriptionsExpiringSoon: sqliteSelectWebPushSubscriptionsExpiringSoonQuery,
insertSubscription: sqliteInsertWebPushSubscriptionQuery,
updateSubscriptionWarningSent: sqliteUpdateWebPushSubscriptionWarningSentQuery,
updateSubscriptionUpdatedAt: sqliteUpdateWebPushSubscriptionUpdatedAtQuery,
deleteSubscriptionByEndpoint: sqliteDeleteWebPushSubscriptionByEndpointQuery,
deleteSubscriptionByUserID: sqliteDeleteWebPushSubscriptionByUserIDQuery,
deleteSubscriptionByAge: sqliteDeleteWebPushSubscriptionByAgeQuery,
insertSubscriptionTopic: sqliteInsertWebPushSubscriptionTopicQuery,
deleteSubscriptionTopicAll: sqliteDeleteWebPushSubscriptionTopicAllQuery,
deleteSubscriptionTopicWithoutSubscription: sqliteDeleteWebPushSubscriptionTopicWithoutSubscription,
},
}, nil
}
func setupSQLite(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(sqliteSelectWebPushSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewSQLite(db)
}
if schemaVersion > sqliteCurrentWebPushSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentWebPushSchemaVersion)
}
return nil
}
func setupNewSQLite(db *sql.DB) error {
if _, err := db.Exec(sqliteCreateWebPushSubscriptionsTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteInsertWebPushSchemaVersion, sqliteCurrentWebPushSchemaVersion); err != nil {
return err
}
return nil
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,63 @@
package webpush_test
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/webpush"
)
func newTestSQLiteStore(t *testing.T) webpush.Store {
store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "")
require.Nil(t, err)
t.Cleanup(func() { store.Close() })
return store
}
func TestSQLiteStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T) {
testStoreUpsertSubscriptionSubscriptionsForTopic(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T) {
testStoreUpsertSubscriptionSubscriberIPLimitReached(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUpsertSubscriptionUpdateTopics(t *testing.T) {
testStoreUpsertSubscriptionUpdateTopics(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreUpsertSubscriptionUpdateFields(t *testing.T) {
testStoreUpsertSubscriptionUpdateFields(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreRemoveByUserIDMultiple(t *testing.T) {
testStoreRemoveByUserIDMultiple(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreRemoveByEndpoint(t *testing.T) {
testStoreRemoveByEndpoint(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreRemoveByUserID(t *testing.T) {
testStoreRemoveByUserID(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreRemoveByUserIDEmpty(t *testing.T) {
testStoreRemoveByUserIDEmpty(t, newTestSQLiteStore(t))
}
func TestSQLiteStoreExpiryWarningSent(t *testing.T) {
store := newTestSQLiteStore(t)
testStoreExpiryWarningSent(t, store, store.SetSubscriptionUpdatedAt)
}
func TestSQLiteStoreExpiring(t *testing.T) {
store := newTestSQLiteStore(t)
testStoreExpiring(t, store, store.SetSubscriptionUpdatedAt)
}
func TestSQLiteStoreRemoveExpired(t *testing.T) {
store := newTestSQLiteStore(t)
testStoreRemoveExpired(t, store, store.SetSubscriptionUpdatedAt)
}

213
webpush/store_test.go Normal file
View File

@@ -0,0 +1,213 @@
package webpush_test
import (
"fmt"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/webpush"
)
const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
func testStoreUpsertSubscriptionSubscriptionsForTopic(t *testing.T, store webpush.Store) {
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
subs, err := store.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
require.Equal(t, subs[0].P256dh, "p256dh-key")
require.Equal(t, subs[0].Auth, "auth-key")
require.Equal(t, subs[0].UserID, "u_1234")
subs2, err := store.SubscriptionsForTopic("mytopic")
require.Nil(t, err)
require.Len(t, subs2, 1)
require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint)
}
func testStoreUpsertSubscriptionSubscriberIPLimitReached(t *testing.T, store webpush.Store) {
// Insert 10 subscriptions with the same IP address
for i := 0; i < 10; i++ {
endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i)
require.Nil(t, store.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
}
// Another one for the same endpoint should be fine
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
// But with a different endpoint it should fail
require.Equal(t, webpush.ErrWebPushTooManySubscriptions, store.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
// But with a different IP address it should be fine again
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"}))
}
func testStoreUpsertSubscriptionUpdateTopics(t *testing.T, store webpush.Store) {
// Insert subscription with two topics, and another with one topic
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"}))
subs, err := store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 2)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint)
subs, err = store.SubscriptionsForTopic("topic2")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
// Update the first subscription to have only one topic
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
subs, err = store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 2)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
subs, err = store.SubscriptionsForTopic("topic2")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func testStoreUpsertSubscriptionUpdateFields(t *testing.T, store webpush.Store) {
// Insert a subscription
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
subs, err := store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, "auth-key", subs[0].Auth)
require.Equal(t, "p256dh-key", subs[0].P256dh)
require.Equal(t, "u_1234", subs[0].UserID)
// Re-upsert the same endpoint with different auth, p256dh, and userID
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "new-auth", "new-p256dh", "u_5678", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
subs, err = store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
require.Equal(t, "new-auth", subs[0].Auth)
require.Equal(t, "new-p256dh", subs[0].P256dh)
require.Equal(t, "u_5678", subs[0].UserID)
}
func testStoreRemoveByUserIDMultiple(t *testing.T, store webpush.Store) {
// Insert two subscriptions for u_1234 and one for u_5678
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint+"2", "auth-key", "p256dh-key", "u_5678", netip.MustParseAddr("9.9.9.9"), []string{"topic1"}))
subs, err := store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 3)
// Remove all subscriptions for u_1234
require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234"))
// Only u_5678's subscription should remain
subs, err = store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint+"2", subs[0].Endpoint)
require.Equal(t, "u_5678", subs[0].UserID)
}
func testStoreRemoveByEndpoint(t *testing.T, store webpush.Store) {
// Insert subscription with two topics
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// And remove it again
require.Nil(t, store.RemoveSubscriptionsByEndpoint(testWebPushEndpoint))
subs, err = store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func testStoreRemoveByUserID(t *testing.T, store webpush.Store) {
// Insert subscription with two topics
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// And remove it again
require.Nil(t, store.RemoveSubscriptionsByUserID("u_1234"))
subs, err = store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func testStoreRemoveByUserIDEmpty(t *testing.T, store webpush.Store) {
require.Equal(t, webpush.ErrWebPushUserIDCannotBeEmpty, store.RemoveSubscriptionsByUserID(""))
}
func testStoreExpiryWarningSent(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) {
// Insert subscription with two topics
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
// Set updated_at to the past so it shows up as expiring
require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix()))
// Verify subscription appears in expiring list (warned_at == 0)
subs, err := store.SubscriptionsExpiring(7 * 24 * time.Hour)
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
// Mark them as warning sent
require.Nil(t, store.MarkExpiryWarningSent(subs))
// Verify subscription no longer appears in expiring list (warned_at > 0)
subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour)
require.Nil(t, err)
require.Len(t, subs, 0)
}
func testStoreExpiring(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) {
// Insert subscription with two topics
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Fake-mark them as soon-to-expire
require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-8*24*time.Hour).Unix()))
// Should not be cleaned up yet
require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour))
// Run expiration
subs, err = store.SubscriptionsExpiring(7 * 24 * time.Hour)
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
}
func testStoreRemoveExpired(t *testing.T, store webpush.Store, setUpdatedAt func(endpoint string, updatedAt int64) error) {
// Insert subscription with two topics
require.Nil(t, store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Fake-mark them as expired
require.Nil(t, setUpdatedAt(testWebPushEndpoint, time.Now().Add(-10*24*time.Hour).Unix()))
// Run expiration
require.Nil(t, store.RemoveExpiredSubscriptions(9*24*time.Hour))
// List again, should be 0
subs, err = store.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}

21
webpush/types.go Normal file
View File

@@ -0,0 +1,21 @@
package webpush
import "heckel.io/ntfy/v2/log"
// Subscription represents a web push subscription.
type Subscription struct {
ID string
Endpoint string
Auth string
P256dh string
UserID string
}
// Context returns the logging context for the subscription.
func (w *Subscription) Context() log.Context {
return map[string]any{
"web_push_subscription_id": w.ID,
"web_push_subscription_user_id": w.UserID,
"web_push_subscription_endpoint": w.Endpoint,
}
}