Compare commits
100 Commits
cancel-sch
...
postgres-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae5e1fe8d8 | ||
|
|
e3a402ed95 | ||
|
|
1abc1005d0 | ||
|
|
909c3fe17b | ||
|
|
07c3e280bf | ||
|
|
b567b4e904 | ||
|
|
60fa50f0d5 | ||
|
|
ceda5ec3d8 | ||
|
|
3d72845c81 | ||
|
|
82e15d84bd | ||
|
|
4e5f95ba0c | ||
|
|
869b972a50 | ||
|
|
bdd20197b3 | ||
|
|
a8dcecdb6d | ||
|
|
5331437664 | ||
|
|
e432bf2886 | ||
|
|
0edad84d86 | ||
|
|
ddf728acd1 | ||
|
|
b1d3671dbb | ||
|
|
3e6b46ec0c | ||
|
|
b16d381626 | ||
|
|
3bd1a1ea03 | ||
|
|
7adb37b94b | ||
|
|
bc08819525 | ||
|
|
a03a37feb1 | ||
|
|
4cd556f5aa | ||
|
|
90aeb811ff | ||
|
|
c6ab380ea4 | ||
|
|
7860f2142c | ||
|
|
18d5d31bd2 | ||
|
|
cfdc364e3f | ||
|
|
763215ecfa | ||
|
|
3f0a7b65ee | ||
|
|
65050ef4dc | ||
|
|
3647d3975c | ||
|
|
06ea1f98ac | ||
|
|
2827df26ee | ||
|
|
fe6ee1efa0 | ||
|
|
14df6462df | ||
|
|
b9f659c8ac | ||
|
|
623fd4f224 | ||
|
|
49991d5aa7 | ||
|
|
1b554d5b08 | ||
|
|
7b0eb3d467 | ||
|
|
6978fa69a8 | ||
|
|
a1da18b99f | ||
|
|
570b188a88 | ||
|
|
b34d23870b | ||
|
|
08eaafa77b | ||
|
|
325983deaf | ||
|
|
fe386e31dd | ||
|
|
bfb47c4046 | ||
|
|
ad334178de | ||
|
|
68e22ebe7d | ||
|
|
23aff6fb06 | ||
|
|
e01e9d6491 | ||
|
|
f382a13109 | ||
|
|
b2f4046574 | ||
|
|
fd836cacf6 | ||
|
|
7207839b2a | ||
|
|
9fbf5e460e | ||
|
|
08bf71b248 | ||
|
|
b3d246d1f8 | ||
|
|
946a2b6fbe | ||
|
|
b57bc5d86e | ||
|
|
62f3d991b4 | ||
|
|
1cf23a6f86 | ||
|
|
63e9b8425f | ||
|
|
4546eb02a1 | ||
|
|
c33f1494f5 | ||
|
|
27a4a71b23 | ||
|
|
11a8d2e3b4 | ||
|
|
f15a74521a | ||
|
|
df22835932 | ||
|
|
eb3549eedc | ||
|
|
cea5fececb | ||
|
|
f686b8c548 | ||
|
|
448c5bfb88 | ||
|
|
5c3fad28be | ||
|
|
f79c84e99e | ||
|
|
1a172eb73b | ||
|
|
b26546b709 | ||
|
|
2ae962d957 | ||
|
|
2343ce46bd | ||
|
|
a12e18cf12 | ||
|
|
64309c0101 | ||
|
|
b9844f48f1 | ||
|
|
77872f1b6a | ||
|
|
7a7c94cd40 | ||
|
|
01ef1d3004 | ||
|
|
d8232e539a | ||
|
|
860954bdc8 | ||
|
|
f4fe62bd91 | ||
|
|
4b474a89b7 | ||
|
|
5ba1c71140 | ||
|
|
de81865c27 | ||
|
|
ed9c1bcb78 | ||
|
|
190d12cd54 | ||
|
|
63bf82e915 | ||
|
|
0e79c4bd2a |
@@ -40,6 +40,7 @@ ADD ./log ./log
|
||||
ADD ./server ./server
|
||||
ADD ./user ./user
|
||||
ADD ./util ./util
|
||||
ADD ./payments ./payments
|
||||
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||
|
||||
FROM alpine
|
||||
|
||||
14
Makefile
@@ -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
|
||||
|
||||
@@ -245,6 +246,7 @@ cli-deps-gcc-windows:
|
||||
|
||||
cli-deps-update:
|
||||
go get -u
|
||||
go mod tidy
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
go install golang.org/x/lint/golint@latest
|
||||
go install github.com/goreleaser/goreleaser/v2@latest
|
||||
|
||||
@@ -34,6 +34,12 @@ You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There
|
||||
available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/),
|
||||
as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
<p>
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img height="50" src="docs/static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="docs/static/img/badge-fdroid.svg"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img height="50" src="docs/static/img/badge-appstore.png"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src=".github/images/screenshot-curl.png" height="180">
|
||||
<img src=".github/images/screenshot-web-detail.png" height="180">
|
||||
|
||||
@@ -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
|
||||
|
||||
25
cmd/user.go
@@ -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) {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c
|
||||
|
||||
## Step 1: Get the app
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.svg"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="static/img/badge-appstore.png"></a>
|
||||
|
||||
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid.
|
||||
|
||||
102
docs/install.md
@@ -30,37 +30,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.17.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.17.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.17.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.17.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -71,7 +71,7 @@ deb/rpm packages.
|
||||
The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely
|
||||
go away soon. I suspect I will phase it out some time in early 2026.
|
||||
|
||||
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 6B7C CFDB 962D 4F1E C4AF`):
|
||||
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 B6B7 CFDB 962D 4F1E C4AF`):
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
@@ -116,7 +116,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -124,7 +124,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -132,7 +132,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -140,7 +140,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -150,33 +150,35 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
## Arch Linux
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
|
||||
You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
|
||||
build and install ntfy and keep it up to date.
|
||||
@@ -191,7 +193,9 @@ cd ntfysh-bin
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## NixOS / Nix
|
||||
## NixOS / Nix
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
|
||||
```
|
||||
nix-env -iA ntfy-sh
|
||||
@@ -199,20 +203,28 @@ nix-env -iA ntfy-sh
|
||||
|
||||
NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy).
|
||||
|
||||
## FreeBSD
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
ntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`:
|
||||
```
|
||||
pkg install go-ntfy
|
||||
```
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_darwin_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_darwin_all.tar.gz > ntfy_2.17.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.17.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.17.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -221,6 +233,8 @@ ntfy --help
|
||||
development as well. Check out the [build instructions](develop.md) for details.
|
||||
|
||||
## Homebrew
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS),
|
||||
simply run:
|
||||
```
|
||||
@@ -231,7 +245,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
@@ -553,18 +567,18 @@ kubectl apply -k /ntfy
|
||||
cpu: 150m
|
||||
memory: 150Mi
|
||||
volumeMounts:
|
||||
- mountPath: /etc/ntfy
|
||||
subPath: server.yml
|
||||
name: config-volume # generated vie configMapGenerator from kustomization file
|
||||
- mountPath: /var/cache/ntfy
|
||||
name: cache-volume #cache volume mounted to persistent volume
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap: # uses configmap generator to parse server.yml to configmap
|
||||
name: server-config
|
||||
- name: cache-volume
|
||||
persistentVolumeClaim: # stores /cache/ntfy in defined pv
|
||||
claimName: ntfy-pvc
|
||||
- mountPath: /etc/ntfy/server.yml
|
||||
subPath: server.yml
|
||||
name: config-volume # generated via configMapGenerator from kustomization file
|
||||
- mountPath: /var/cache/ntfy
|
||||
name: cache-volume # cache volume mounted to persistent volume
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap: # uses configmap generator to parse server.yml to configmap
|
||||
name: server-config
|
||||
- name: cache-volume
|
||||
persistentVolumeClaim: # stores /cache/ntfy in defined pv
|
||||
claimName: ntfy-pvc
|
||||
```
|
||||
|
||||
=== "ntfy-pvc.yaml"
|
||||
|
||||
@@ -182,6 +182,9 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.
|
||||
- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)
|
||||
- [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
|
||||
|
||||
|
||||
329
docs/publish.md
@@ -1,6 +1,6 @@
|
||||
# Publishing
|
||||
Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](install.md). Topics are created on the fly by
|
||||
subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
|
||||
Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)).
|
||||
Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
|
||||
something that's not easily guessable.
|
||||
|
||||
Here's an example showing how to publish a simple message using a POST request:
|
||||
@@ -641,7 +641,7 @@ You can format messages using [Markdown](https://www.markdownguide.org/basic-syn
|
||||
|
||||
By default, messages sent to ntfy are rendered as plain text. To enable Markdown, set the `X-Markdown` header (or any of
|
||||
its aliases: `Markdown`, or `md`) to `true` (or `1` or `yes`), or set the `Content-Type` header to `text/markdown`.
|
||||
As of today, **Markdown is only supported in the web app.** Here's an example of how to enable Markdown formatting:
|
||||
Here's an example of how to enable Markdown formatting:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
@@ -1134,6 +1134,7 @@ As of today, the following actions are supported:
|
||||
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
||||
when the action button is tapped (only supported on Android)
|
||||
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
|
||||
* [`copy`](#copy-to-clipboard): Copies a given value to the clipboard when the action button is tapped
|
||||
|
||||
Here's an example of what a notification with actions can look like:
|
||||
|
||||
@@ -1164,9 +1165,12 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions`
|
||||
Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be
|
||||
quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons.
|
||||
|
||||
The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and
|
||||
`http` action. The only limitation of this format is that depending on your language/library, UTF-8 characters may not
|
||||
work. If they don't, use the [JSON array format](#using-a-json-array) instead.
|
||||
Each action type has a short format where some key prefixes can be omitted:
|
||||
|
||||
* [`view`](#open-websiteapp): `view, <label>, <url>[, clear=true]`
|
||||
* [`broadcast`](#send-android-broadcast):`broadcast, <label>[, extras.<param>=<value>][, intent=<intent>][, clear=true]`
|
||||
* [`http`](#send-http-request): `http, <label>, <url>[, method=<method>][, headers.<header>=<value>][, body=<body>][, clear=true]`
|
||||
* [`copy`](#copy-to-clipboard): `copy, <label>, <value>[, clear=true]`
|
||||
|
||||
As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and
|
||||
[`http` action](#send-http-request) section for details on the specific actions:
|
||||
@@ -1466,8 +1470,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
```
|
||||
|
||||
The required/optional fields for each action depend on the type of the action itself. Please refer to
|
||||
[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request)
|
||||
for details.
|
||||
[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), [`http` action](#send-http-request),
|
||||
and [`copy` action](#copy-to-clipboard) for details.
|
||||
|
||||
### Open website/app
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
@@ -1619,7 +1623,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
topic: "myhome",
|
||||
message": "Somebody retweeted your tweet.",
|
||||
message: "Somebody retweeted your tweet.",
|
||||
actions: [
|
||||
{
|
||||
action: "view",
|
||||
@@ -1710,6 +1714,9 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
]));
|
||||
```
|
||||
|
||||
The short format for the `view` action is `view, <label>, <url>` (e.g. `view, Open Google, https://google.com`),
|
||||
but you can always just use the `<key>=<value>` notation as well (e.g. `action=view, url=https://google.com, label=Open Google`).
|
||||
|
||||
The `view` action supports the following fields:
|
||||
|
||||
| Field | Required | Type | Default | Example | Description |
|
||||
@@ -1879,7 +1886,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
topic: "wifey",
|
||||
message": "Your wife requested you send a picture of yourself.",
|
||||
message: "Your wife requested you send a picture of yourself.",
|
||||
actions: [
|
||||
{
|
||||
"action": "broadcast",
|
||||
@@ -1986,6 +1993,9 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
]));
|
||||
```
|
||||
|
||||
The short format for the `broadcast` action is `broadcast, <label>, <url>` (e.g. `broadcast, Take picture, extras.cmd=pic`),
|
||||
but you can always just use the `<key>=<value>` notation as well (e.g. `action=broadcast, label=Take picture, extras.cmd=pic`).
|
||||
|
||||
The `broadcast` action supports the following fields:
|
||||
|
||||
| Field | Required | Type | Default | Example | Description |
|
||||
@@ -2154,7 +2164,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
topic: "myhome",
|
||||
message": "Garage door has been open for 15 minutes. Close it?",
|
||||
message: "Garage door has been open for 15 minutes. Close it?",
|
||||
actions: [
|
||||
{
|
||||
"action": "http",
|
||||
@@ -2273,6 +2283,9 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
]));
|
||||
```
|
||||
|
||||
The short format for the `http` action is `http, <label>, <url>` (e.g. `http, Close door, https://api.mygarage.lan/close`),
|
||||
but you can always just use the `<key>=<value>` notation as well (e.g. `action=http, label=Close door, url=https://api.mygarage.lan/close`).
|
||||
|
||||
The `http` action supports the following fields:
|
||||
|
||||
| Field | Required | Type | Default | Example | Description |
|
||||
@@ -2285,6 +2298,254 @@ The `http` action supports the following fields:
|
||||
| `body` | -️ | *string* | *empty* | `some body, somebody?` | HTTP body |
|
||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
|
||||
|
||||
### Copy to clipboard
|
||||
_Supported on:_ :material-android: :material-firefox:
|
||||
|
||||
The `copy` action **copies a given value to the clipboard when the action button is tapped**. This is useful for
|
||||
one-time passcodes, tokens, or any other value you want to quickly copy without opening the full notification.
|
||||
|
||||
!!! info
|
||||
The copy action button is only shown in the web app and Android app notification list, **not** in browser desktop
|
||||
notifications. This is because browsers do not allow clipboard access from notification actions without direct
|
||||
user interaction with the page.
|
||||
|
||||
Here's an example using the [`X-Actions` header](#using-a-header):
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-d "Your one-time passcode is 123456" \
|
||||
-H "Actions: copy, Copy code, 123456" \
|
||||
ntfy.sh/myhome
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--actions="copy, Copy code, 123456" \
|
||||
myhome \
|
||||
"Your one-time passcode is 123456"
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /myhome HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Actions: copy, Copy code, 123456
|
||||
|
||||
Your one-time passcode is 123456
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh/myhome', {
|
||||
method: 'POST',
|
||||
body: 'Your one-time passcode is 123456',
|
||||
headers: {
|
||||
'Actions': 'copy, Copy code, 123456'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Your one-time passcode is 123456"))
|
||||
req.Header.Set("Actions", "copy, Copy code, 123456")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/myhome"
|
||||
Headers = @{
|
||||
Actions = "copy, Copy code, 123456"
|
||||
}
|
||||
Body = "Your one-time passcode is 123456"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/myhome",
|
||||
data="Your one-time passcode is 123456",
|
||||
headers={ "Actions": "copy, Copy code, 123456" })
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/myhome', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' =>
|
||||
"Content-Type: text/plain\r\n" .
|
||||
"Actions: copy, Copy code, 123456",
|
||||
'content' => 'Your one-time passcode is 123456'
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
And the same example using [JSON publishing](#publish-as-json):
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl ntfy.sh \
|
||||
-d '{
|
||||
"topic": "myhome",
|
||||
"message": "Your one-time passcode is 123456",
|
||||
"actions": [
|
||||
{
|
||||
"action": "copy",
|
||||
"label": "Copy code",
|
||||
"value": "123456"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--actions '[
|
||||
{
|
||||
"action": "copy",
|
||||
"label": "Copy code",
|
||||
"value": "123456"
|
||||
}
|
||||
]' \
|
||||
myhome \
|
||||
"Your one-time passcode is 123456"
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST / HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
{
|
||||
"topic": "myhome",
|
||||
"message": "Your one-time passcode is 123456",
|
||||
"actions": [
|
||||
{
|
||||
"action": "copy",
|
||||
"label": "Copy code",
|
||||
"value": "123456"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
topic: "myhome",
|
||||
message: "Your one-time passcode is 123456",
|
||||
actions: [
|
||||
{
|
||||
action: "copy",
|
||||
label: "Copy code",
|
||||
value: "123456"
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
// You should probably use json.Marshal() instead and make a proper struct,
|
||||
// but for the sake of the example, this is easier.
|
||||
|
||||
body := `{
|
||||
"topic": "myhome",
|
||||
"message": "Your one-time passcode is 123456",
|
||||
"actions": [
|
||||
{
|
||||
"action": "copy",
|
||||
"label": "Copy code",
|
||||
"value": "123456"
|
||||
}
|
||||
]
|
||||
}`
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh"
|
||||
Body = ConvertTo-JSON @{
|
||||
Topic = "myhome"
|
||||
Message = "Your one-time passcode is 123456"
|
||||
Actions = @(
|
||||
@{
|
||||
Action = "copy"
|
||||
Label = "Copy code"
|
||||
Value = "123456"
|
||||
}
|
||||
)
|
||||
}
|
||||
ContentType = "application/json"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/",
|
||||
data=json.dumps({
|
||||
"topic": "myhome",
|
||||
"message": "Your one-time passcode is 123456",
|
||||
"actions": [
|
||||
{
|
||||
"action": "copy",
|
||||
"label": "Copy code",
|
||||
"value": "123456"
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json",
|
||||
'content' => json_encode([
|
||||
"topic": "myhome",
|
||||
"message": "Your one-time passcode is 123456",
|
||||
"actions": [
|
||||
[
|
||||
"action": "copy",
|
||||
"label": "Copy code",
|
||||
"value": "123456"
|
||||
]
|
||||
]
|
||||
])
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
The short format for the `copy` action is `copy, <label>, <value>` (e.g. `copy, Copy code, 123456`),
|
||||
but you can always just use the `<key>=<value>` notation as well (e.g. `action=copy, label=Copy code, value=123456`).
|
||||
|
||||
The `copy` action supports the following fields:
|
||||
|
||||
| Field | Required | Type | Default | Example | Description |
|
||||
|----------|----------|-----------|---------|-----------------|--------------------------------------------------|
|
||||
| `action` | ✔️ | *string* | - | `copy` | Action type (**must be `copy`**) |
|
||||
| `label` | ✔️ | *string* | - | `Copy code` | Label of the action button in the notification |
|
||||
| `value` | ✔️ | *string* | - | `123456` | Value to copy to the clipboard |
|
||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
||||
|
||||
## Scheduled delivery
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
|
||||
@@ -2392,18 +2653,17 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
|
||||
</tr></table>
|
||||
|
||||
### Updating scheduled notifications
|
||||
|
||||
You can update or replace a scheduled message before it is delivered by publishing a new message with the same
|
||||
[sequence ID](#updating-deleting-notifications). When you do this, the **original scheduled message is deleted**
|
||||
from the server and replaced with the new one. This is different from [updating notifications](#updating-notifications)
|
||||
after delivery, where both messages are kept in the cache.
|
||||
|
||||
This is particularly useful for implementing a **watchdog that triggers when your script stops sending heartbeat messages**.
|
||||
This mechanism is also called a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch). The idea is to have
|
||||
a mechanism that triggers an alert if it's not periodically reset.
|
||||
This mechanism is also called a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch).
|
||||
|
||||
For example, you could schedule a message to be
|
||||
delivered in 5 minutes, but continuously update it every minute to push the delivery time further into the future.
|
||||
If your script or system stops running, the message will eventually be delivered as an alert.
|
||||
For example, you could schedule a message to be delivered in 5 minutes, but continuously update it every minute to push
|
||||
the delivery time further into the future. If your script or system stops running, the message will eventually be delivered as an alert.
|
||||
|
||||
Here's an example of a dead man's switch that sends an alert if the script stops running for more than 5 minutes:
|
||||
|
||||
@@ -2515,6 +2775,7 @@ Here's an example of a dead man's switch that sends an alert if the script stops
|
||||
```
|
||||
|
||||
### Canceling scheduled notifications
|
||||
|
||||
You can cancel a scheduled message before it is delivered by sending a DELETE request to the
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the
|
||||
scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers.
|
||||
@@ -2643,7 +2904,7 @@ You can enable templating by setting the `X-Template` header (or its aliases `Te
|
||||
will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`).
|
||||
See [custom templates](#custom-templates) for more details.
|
||||
* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`)
|
||||
will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template.
|
||||
will enable inline templating, which means that the `message`, `title`, and/or `priority` will be parsed as a Go template.
|
||||
See [inline templating](#inline-templating) for more details.
|
||||
|
||||
To learn the basics of Go's templating language, please see [template syntax](#template-syntax).
|
||||
@@ -2686,7 +2947,7 @@ and set the `X-Template` header or query parameter to the name of the template f
|
||||
For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or
|
||||
the query parameter `?template=myapp` to use it.
|
||||
|
||||
Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys,
|
||||
Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title`, `message`, and `priority` keys,
|
||||
which are interpreted as Go templates.
|
||||
|
||||
Here's an **example custom template**:
|
||||
@@ -2704,6 +2965,11 @@ Here's an **example custom template**:
|
||||
Status: {{ .status }}
|
||||
Type: {{ .type | upper }} ({{ .percent }}%)
|
||||
Server: {{ .server }}
|
||||
priority: |
|
||||
{{ if gt .percent 90.0 }}5
|
||||
{{ else if gt .percent 75.0 }}4
|
||||
{{ else }}3
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Once you have the template file in place, you can send the payload to your topic using the `X-Template`
|
||||
@@ -2785,7 +3051,7 @@ Which will result in a notification that looks like this:
|
||||
|
||||
### Inline templating
|
||||
|
||||
When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your
|
||||
When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message`, `title`, and `priority` fields of your
|
||||
webhook payload.
|
||||
|
||||
Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh).
|
||||
@@ -2841,12 +3107,12 @@ Here's an **easier example with a shorter JSON payload**:
|
||||
curl \
|
||||
--globoff \
|
||||
-d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \
|
||||
'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}'
|
||||
'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}'
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}} HTTP/1.1
|
||||
POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}} HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}
|
||||
@@ -2854,7 +3120,7 @@ Here's an **easier example with a shorter JSON payload**:
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}', {
|
||||
fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}', {
|
||||
method: 'POST',
|
||||
body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
|
||||
})
|
||||
@@ -2863,7 +3129,7 @@ Here's an **easier example with a shorter JSON payload**:
|
||||
=== "Go"
|
||||
``` go
|
||||
body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}`
|
||||
uri := "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}"
|
||||
uri := `https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if eq .error.level "severe"}}5{{else}}3{{end}}`
|
||||
req, _ := http.NewRequest("POST", uri, strings.NewReader(body))
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
@@ -2873,7 +3139,7 @@ Here's an **easier example with a shorter JSON payload**:
|
||||
``` powershell
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}"
|
||||
URI = 'https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}'
|
||||
Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
|
||||
ContentType = "application/json"
|
||||
}
|
||||
@@ -2883,14 +3149,14 @@ Here's an **easier example with a shorter JSON payload**:
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post(
|
||||
"https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}",
|
||||
'https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}',
|
||||
data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
|
||||
)
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents("https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", false, stream_context_create([
|
||||
file_get_contents('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json",
|
||||
@@ -2899,9 +3165,9 @@ Here's an **easier example with a shorter JSON payload**:
|
||||
]));
|
||||
```
|
||||
|
||||
This example uses the `message`/`m` and `title`/`t` query parameters, but obviously this also works with the corresponding
|
||||
`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message
|
||||
`Error message: Disk has run out of space`.
|
||||
This example uses the `message`/`m`, `title`/`t`, and `priority`/`p` query parameters, but obviously this also works with the
|
||||
corresponding headers. It will send a notification with a title `phil-pc: A severe error has occurred`, a message
|
||||
`Error message: Disk has run out of space`, and priority `5` (max) if the level is "severe", or `3` (default) otherwise.
|
||||
|
||||
### Template syntax
|
||||
ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful,
|
||||
@@ -2920,7 +3186,7 @@ your templates there first ([example for Grafana alert](https://repeatit.io/#/sh
|
||||
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
|
||||
thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
|
||||
|
||||
Below are the functions that are available to use inside your message/title templates.
|
||||
Below are the functions that are available to use inside your message, title, and priority templates.
|
||||
|
||||
* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc.
|
||||
* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc.
|
||||
@@ -3503,9 +3769,6 @@ Here's an example with a custom message, tags and a priority:
|
||||
## Updating + deleting notifications
|
||||
_Supported on:_ :material-android: :material-firefox:
|
||||
|
||||
!!! info
|
||||
**This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later.
|
||||
|
||||
You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios
|
||||
like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant.
|
||||
|
||||
|
||||
150
docs/releases.md
@@ -6,12 +6,104 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.15.0 | Nov 16, 2025 |
|
||||
| ntfy Android app | v1.21.1 | Jan 6, 2025 |
|
||||
| ntfy server | v2.17.0 | Feb 8, 2026 |
|
||||
| ntfy Android app | v1.22.2 | Jan 25, 2026 |
|
||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
## ntfy server v2.17.0
|
||||
Released February 8, 2026
|
||||
|
||||
This release adds support for templating in the priority field, a new "copy" action button to copy values to the clipboard,
|
||||
a red notification dot on the favicon for unread messages, and an admin-only version endpoint. It also includes several
|
||||
crash fixes, web app improvements, and documentation updates.
|
||||
|
||||
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`),
|
||||
or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy will always remain open source.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
|
||||
* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting)
|
||||
* Server/Web: [Support "copy" action](publish.md#copy-to-clipboard) button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
|
||||
* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)
|
||||
* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598))
|
||||
* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)
|
||||
* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
|
||||
* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
|
||||
* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting)
|
||||
* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting)
|
||||
* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting)
|
||||
* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR)
|
||||
* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts))
|
||||
* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
|
||||
* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
|
||||
* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
|
||||
|
||||
## ntfy Android app v1.22.2
|
||||
Released January 20, 2026
|
||||
|
||||
This release adds support for [updating and deleting notifications](publish.md#updating-deleting-notifications) (requires server v2.16.0),
|
||||
as well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates),
|
||||
and a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting).
|
||||
|
||||
<div id="v1221-screenshots-1" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-notification-update-1.png"><img src="../../static/img/android-screenshot-notification-update-1.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-notification-update-2.png"><img src="../../static/img/android-screenshot-notification-update-2.png"/></a>
|
||||
</div>
|
||||
|
||||
<div id="v1221-screenshots-2" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
|
||||
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
|
||||
for the initial implementation)
|
||||
* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
|
||||
[#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
|
||||
thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
|
||||
* Connection error dialog to help diagnose connection issues
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
|
||||
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
|
||||
* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
|
||||
* Fix crash when exiting multi-delete in detail view
|
||||
* Fix potential crashes with icon downloader and backuper
|
||||
|
||||
## ntfy server v2.16.0
|
||||
Released January 19, 2026
|
||||
|
||||
This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,
|
||||
custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app.
|
||||
|
||||
This one is very exciting, as it brings a lot of highly requested features to ntfy.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
||||
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
|
||||
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
|
||||
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
|
||||
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
|
||||
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
||||
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
|
||||
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
|
||||
thanks to [@wtf911](https://github.com/wtf911))
|
||||
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
|
||||
|
||||
## ntfy Android app v1.21.1
|
||||
Released January 6, 2026
|
||||
|
||||
@@ -19,6 +111,13 @@ This is the first feature release in a long time. After all the SDK updates, fix
|
||||
and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),
|
||||
support for passing headers to your proxy, an in-app language switcher, and more.
|
||||
|
||||
<div id="v1211-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
|
||||
</div>
|
||||
|
||||
If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).
|
||||
If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.
|
||||
|
||||
@@ -1599,39 +1698,26 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.16.x (UNRELEASED)
|
||||
### ntfy Android v1.23.x (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
||||
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
|
||||
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
|
||||
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
|
||||
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
|
||||
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
||||
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
|
||||
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
|
||||
thanks to [@wtf911](https://github.com/wtf911))
|
||||
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
|
||||
|
||||
### ntfy Android app v1.22.x (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
|
||||
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
|
||||
for the initial implementation)
|
||||
* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
|
||||
[#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
|
||||
thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
|
||||
* Connection error dialog to help diagnose connection issues
|
||||
* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring)
|
||||
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
|
||||
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
|
||||
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
|
||||
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
|
||||
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
|
||||
* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
|
||||
* Fix crash when exiting multi-delete in detail view
|
||||
* Fix potential crashes with icon downloader and backuper
|
||||
* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
|
||||
* 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
|
||||
|
||||
29
docs/static/css/extra.css
vendored
@@ -92,7 +92,7 @@ figure video {
|
||||
}
|
||||
|
||||
.screenshots img {
|
||||
max-height: 230px;
|
||||
max-height: 350px;
|
||||
max-width: 350px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
@@ -214,3 +214,30 @@ figure video {
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Community maintained badge */
|
||||
.community-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
background-color: rgba(51, 133, 116, 0.1);
|
||||
border: 1px solid rgba(51, 133, 116, 0.3);
|
||||
border-radius: 0.7em;
|
||||
padding: 0.1em 0.7em;
|
||||
font-size: 0.75rem;
|
||||
color: #338574;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.community-badge svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .community-badge {
|
||||
background-color: rgba(86, 189, 168, 0.15);
|
||||
border-color: rgba(86, 189, 168, 0.4);
|
||||
color: #56bda8;
|
||||
}
|
||||
|
||||
BIN
docs/static/img/android-screenshot-certs-manage.jpg
vendored
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/static/img/android-screenshot-certs-warning-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/static/img/android-screenshot-connection-error-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
docs/static/img/android-screenshot-connection-error-warning.jpg
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/static/img/android-screenshot-custom-headers-add.jpg
vendored
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/static/img/android-screenshot-custom-headers.jpg
vendored
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/static/img/android-screenshot-language-chinese.jpg
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/static/img/android-screenshot-language-german.jpg
vendored
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
docs/static/img/android-screenshot-language-hebrew.jpg
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/static/img/android-screenshot-language-selection.jpg
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/static/img/android-screenshot-publish-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/static/img/android-screenshot-publish-message-bar.jpg
vendored
Normal file
|
After Width: | Height: | Size: 95 KiB |
240
docs/static/img/badge-fdroid.svg
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="43 43 560 164"
|
||||
version="1.1"
|
||||
id="svg78"
|
||||
sodipodi:docname="get-it-on-en.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview80"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<defs
|
||||
id="defs8">
|
||||
<radialGradient
|
||||
xlink:href="#a"
|
||||
id="b"
|
||||
cx="113"
|
||||
cy="-12.89"
|
||||
r="59.662"
|
||||
fx="113"
|
||||
fy="-12.89"
|
||||
gradientTransform="matrix(0 1.96105 -1.97781 0 254.507 78.763)"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
id="a">
|
||||
<stop
|
||||
offset="0"
|
||||
style="stop-color:#fff;stop-opacity:.09803922"
|
||||
id="stop3" />
|
||||
<stop
|
||||
offset="1"
|
||||
style="stop-color:#fff;stop-opacity:0"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g
|
||||
transform="translate(-289,-312.362)"
|
||||
id="g76">
|
||||
<path
|
||||
id="rect10"
|
||||
style="display:inline;overflow:visible;stroke:#a6a6a6;stroke-width:4;marker:none"
|
||||
d="m 352,355.362 h 520 c 11.08,0 20,8.92 20,20 v 124 c 0,11.08 -8.92,20 -20,20 H 352 c -11.08,0 -20,-8.92 -20,-20 v -124 c 0,-11.08 8.92,-20 20,-20 z" />
|
||||
<g
|
||||
aria-label="GET IT ON"
|
||||
id="text14"
|
||||
style="font-size:12.3952px;line-height:100%;font-family:'DejaVu Sans';-inkscape-font-specification:'DejaVu Sans';letter-spacing:0;word-spacing:0;display:inline;overflow:visible;fill:#ffffff;stroke-width:1px;marker:none">
|
||||
<path
|
||||
d="m 529.2627,398.81787 v -6.6817 h -5.49866 v -2.76599 h 8.83117 v 10.68072 q -1.94952,1.383 -4.29895,2.09949 -2.34942,0.69983 -5.01544,0.69983 -5.83191,0 -9.1311,-3.39917 -3.28253,-3.41583 -3.28253,-9.49768 0,-6.09851 3.28253,-9.49768 3.29919,-3.41583 9.1311,-3.41583 2.43274,0 4.61554,0.59985 2.19947,0.59985 4.04901,1.76623 v 3.58246 q -1.86621,-1.58294 -3.96569,-2.38275 -2.09949,-0.7998 -4.41559,-0.7998 -4.56555,0 -6.86499,2.54937 -2.28278,2.54938 -2.28278,7.59815 0,5.0321 2.28278,7.58148 2.29944,2.54938 6.86499,2.54938 1.78289,0 3.18255,-0.29993 1.39966,-0.31659 2.51606,-0.96643 z"
|
||||
style="font-size:34.125px"
|
||||
id="path83" />
|
||||
<path
|
||||
d="m 538.74371,377.48975 h 15.7295 v 2.83264 h -12.36365 v 7.36487 h 11.84711 v 2.83264 h -11.84711 v 9.01446 h 12.66357 v 2.83264 h -16.02942 z"
|
||||
style="font-size:34.125px"
|
||||
id="path85" />
|
||||
<path
|
||||
d="m 556.85596,377.48975 h 21.04486 v 2.83264 h -8.83118 V 402.367 h -3.38251 v -22.04461 h -8.83117 z"
|
||||
style="font-size:34.125px"
|
||||
id="path87" />
|
||||
<path
|
||||
d="m 591.99738,377.48975 h 3.36584 V 402.367 h -3.36584 z"
|
||||
style="font-size:34.125px"
|
||||
id="path89" />
|
||||
<path
|
||||
d="m 598.61243,377.48975 h 21.04486 v 2.83264 h -8.83118 V 402.367 h -3.38251 v -22.04461 h -8.83117 z"
|
||||
style="font-size:34.125px"
|
||||
id="path91" />
|
||||
<path
|
||||
d="m 643.85138,379.77252 q -3.66577,0 -5.83191,2.73267 -2.14947,2.73266 -2.14947,7.44818 0,4.69885 2.14947,7.43152 2.16614,2.73266 5.83191,2.73266 3.66577,0 5.79858,-2.73266 2.14948,-2.73267 2.14948,-7.43152 0,-4.71552 -2.14948,-7.44818 -2.13281,-2.73267 -5.79858,-2.73267 z m 0,-2.73266 q 5.23206,0 8.36462,3.5158 3.13257,3.49915 3.13257,9.39771 0,5.8819 -3.13257,9.3977 -3.13256,3.49915 -8.36462,3.49915 -5.24872,0 -8.39795,-3.49915 -3.13257,-3.49914 -3.13257,-9.3977 0,-5.89856 3.13257,-9.39771 3.14923,-3.5158 8.39795,-3.5158 z"
|
||||
style="font-size:34.125px"
|
||||
id="path93" />
|
||||
<path
|
||||
d="m 660.61395,377.48975 h 4.53223 l 11.03064,20.81158 v -20.81158 h 3.26587 V 402.367 h -4.53223 L 663.87982,381.55542 V 402.367 h -3.26587 z"
|
||||
style="font-size:34.125px"
|
||||
id="path95" />
|
||||
</g>
|
||||
<g
|
||||
aria-label="F-Droid"
|
||||
id="text18"
|
||||
style="font-weight:700;font-size:29.7088px;line-height:100%;font-family:Rokkitt;-inkscape-font-specification:'Rokkitt Bold';letter-spacing:0;word-spacing:0;display:inline;overflow:visible;fill:#ffffff;stroke-width:1px;marker:none">
|
||||
<path
|
||||
d="m 510.81067,481.24332 v 8.11767 h 27.97119 v -8.11767 l -7.23633,-1.3916 v -18.55469 h 23.65723 v -10.43701 h -23.65723 v -18.60108 h 22.03369 l 0.60303,8.07129 h 10.39063 v -18.5083 h -53.76221 v 8.16406 l 7.18994,1.3916 v 48.47413 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path98" />
|
||||
<path
|
||||
d="m 599.13098,465.70377 v -10.43702 h -26.16211 v 10.43702 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path100" />
|
||||
<path
|
||||
d="m 637.67834,421.82193 h -30.3833 v 8.16406 l 7.18995,1.3916 v 48.47413 l -7.18995,1.3916 v 8.11767 h 30.3833 c 16.51368,0 28.43506,-11.59668 28.43506,-28.15674 v -11.1792 c 0,-16.51367 -11.92138,-28.20312 -28.43506,-28.20312 z m -9.64843,10.43701 h 8.95263 c 9.69483,0 15.53955,7.23633 15.53955,17.67334 v 11.27197 c 0,10.57618 -5.84472,17.76612 -15.53955,17.76612 h -8.95263 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path102" />
|
||||
<path
|
||||
d="m 674.09192,481.24332 v 8.11767 h 26.5332 v -8.11767 l -6.49414,-1.3916 v -24.58497 c 1.48438,-2.82959 3.89649,-4.31396 7.88574,-4.12841 l 6.67969,0.3247 1.43799,-12.47802 c -1.29883,-0.46387 -3.43262,-0.74219 -5.33447,-0.74219 -4.87061,0 -8.62793,3.06152 -10.99366,8.25683 l -0.0928,-1.11328 -0.51025,-6.21582 h -19.80713 v 8.16407 l 7.18994,1.3916 v 31.12549 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path104" />
|
||||
<path
|
||||
d="m 713.24231,463.80191 v 0.97412 c 0,15.07569 8.85986,25.55908 23.75,25.55908 14.70459,0 23.61084,-10.48339 23.61084,-25.55908 v -0.97412 c 0,-15.0293 -8.85986,-25.55908 -23.70361,-25.55908 -14.79737,0 -23.65723,10.57617 -23.65723,25.55908 z m 13.54492,0.97412 v -0.97412 c 0,-8.90625 3.06152,-15.12207 10.11231,-15.12207 7.05078,0 10.20507,6.21582 10.20507,15.12207 v 0.97412 c 0,9.0918 -3.10791,15.16846 -10.1123,15.16846 -7.18994,0 -10.20508,-6.03027 -10.20508,-15.16846 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path106" />
|
||||
<path
|
||||
d="M 786.16223,428.548 V 416.99771 H 772.15344 V 428.548 Z m -20.08545,52.69532 v 8.11767 h 26.57959 v -8.11767 l -6.49414,-1.3916 v -40.68116 h -20.78125 v 8.16407 l 7.23633,1.3916 v 31.12549 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path108" />
|
||||
<path
|
||||
d="m 829.76575,483.23795 1.0205,6.12304 h 18.22999 v -8.11767 l -6.49415,-1.3916 v -62.85401 h -20.78125 v 8.16406 l 7.23633,1.39161 v 17.99804 c -3.01513,-4.03564 -7.05078,-6.30859 -12.06054,-6.30859 -12.43164,0 -19.62159,10.62256 -19.62159,26.44043 v 0.97412 c 0,14.84375 7.14356,24.67773 19.52881,24.67773 5.52002,0 9.7876,-2.45849 12.9419,-7.09716 z m -18.92578,-17.58057 v -0.97412 c 0,-9.46289 2.87597,-15.91065 9.50927,-15.91065 3.89649,0 6.77246,1.85547 8.62793,5.05616 v 21.2915 c -1.85547,3.01514 -4.77783,4.68506 -8.7207,4.68506 -6.67969,0 -9.4165,-5.38086 -9.4165,-14.14795 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path110" />
|
||||
</g>
|
||||
<path
|
||||
d="m 2.589,1006.862 4.25,5.5"
|
||||
style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
|
||||
id="path20" />
|
||||
<path
|
||||
d="m 2.611,1005.61 c -0.453,0.011 -0.761,0.188 -0.98,0.448 2.027,2.409 2.368,2.792 5.135,6.221 1.02,1.32 2.082,0.638 1.062,-0.681 l -4.25,-5.5 a 1.24,1.24 0 0 0 -0.967,-0.489"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.298039;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
|
||||
id="path22" />
|
||||
<path
|
||||
d="m 1.622,1006.07 a 1.25,1.25 0 0 0 -0.022,1.557 l 4.25,5.5 c 1.02,1.319 1.15,-0.613 1.15,-0.613 0,0 -3.735,-4.51 -5.378,-6.443"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
|
||||
id="path24" />
|
||||
<path
|
||||
d="m 2.338,1005.844 c -0.438,0 -0.96,0.142 -0.824,0.799 0.103,0.501 4.66,6.074 4.66,6.074 1.02,1.32 2.494,0.677 1.474,-0.642 l -4.234,-5.473 c -0.26,-0.29 -0.608,-0.744 -1.076,-0.758"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
|
||||
id="path26" />
|
||||
<path
|
||||
d="m 2.589,1006.862 4.25,5.5"
|
||||
style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
|
||||
id="path28" />
|
||||
<path
|
||||
d="m 2.611,1005.61 c -0.453,0.011 -0.761,0.188 -0.98,0.448 2.027,2.409 2.368,2.792 5.135,6.221 1.02,1.32 2.082,0.638 1.062,-0.681 l -4.25,-5.5 a 1.24,1.24 0 0 0 -0.967,-0.489"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.298039;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
|
||||
id="path30" />
|
||||
<path
|
||||
d="m 1.622,1006.07 a 1.25,1.25 0 0 0 -0.022,1.557 l 4.25,5.5 c 1.02,1.319 1.15,-0.613 1.15,-0.613 0,0 -3.735,-4.51 -5.378,-6.443"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
|
||||
id="path32" />
|
||||
<path
|
||||
d="m 2.338,1005.844 c -0.438,0 -0.96,0.142 -0.824,0.799 0.103,0.501 4.66,6.074 4.66,6.074 1.02,1.32 2.494,0.677 1.474,-0.642 l -4.234,-5.473 c -0.26,-0.29 -0.608,-0.744 -1.076,-0.758"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
|
||||
id="path34" />
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,467.369,-2270.475)"
|
||||
id="g44">
|
||||
<path
|
||||
id="rect36"
|
||||
style="opacity:1;fill:#aeea00;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m -34,1010.36 h 32 c 1.662,0 3,1.338 3,3 v 6.92 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -6.92 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect38"
|
||||
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m -34,1013.279 h 32 c 1.662,0 3,1.338 3,3 v 4 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -4 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect40"
|
||||
style="opacity:1;fill:#ffffff;fill-opacity:0.298039;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m -34,1010.362 h 32 c 1.662,0 3,1.338 3,3 v 4 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -4 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect42"
|
||||
style="opacity:1;fill:#aeea00;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m -34,1011.5 h 32 c 1.662,0 3,1.0954 3,2.456 v 5.729 c 0,1.3606 -1.338,2.456 -3,2.456 h -32 c -1.662,0 -3,-1.0954 -3,-2.456 v -5.729 c 0,-1.3606 1.338,-2.456 3,-2.456 z" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.745)"
|
||||
id="g54">
|
||||
<path
|
||||
id="rect46"
|
||||
style="opacity:1;fill:#1976d2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m 8,1024.522 h 32 c 1.662,0 3,1.338 3,3 v 19.84 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -19.84 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect48"
|
||||
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m 8,1037.3621 h 32 c 1.662,0 3,1.338 3,3 v 7 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -7 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect50"
|
||||
style="opacity:1;fill:#ffffff;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m 8,1024.442 h 32 c 1.662,0 3,1.338 3,3 v 7 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -7 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect52"
|
||||
style="opacity:1;fill:#1976d2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m 8,1025.662 h 32 c 1.662,0 3,1.2122 3,2.718 v 18.124 c 0,1.5058 -1.338,2.718 -3,2.718 H 8 c -1.662,0 -3,-1.2122 -3,-2.718 v -18.124 c 0,-1.5058 1.338,-2.718 3,-2.718 z" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,396.264)"
|
||||
id="g60">
|
||||
<path
|
||||
d="m 24,17.75 c -2.88,0 -5.32,1.985 -6.033,4.65 H 21.18 A 3.22,3.22 0 0 1 24,20.75 3.23,3.23 0 0 1 27.25,24 3.23,3.23 0 0 1 24,27.25 3.22,3.22 0 0 1 21.07,25.4 h -3.154 c 0.642,2.766 3.132,4.85 6.084,4.85 3.434,0 6.25,-2.816 6.25,-6.25 0,-3.434 -2.816,-6.25 -6.25,-6.25"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#0d47a1;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
id="path56" />
|
||||
<path
|
||||
id="circle58"
|
||||
style="opacity:1;fill:none;fill-opacity:0.403922;stroke:#0d47a1;stroke-width:1.9;stroke-linecap:round"
|
||||
d="M 33.55,24 A 9.5500002,9.5500002 0 0 1 24,33.55 9.5500002,9.5500002 0 0 1 14.45,24 9.5500002,9.5500002 0 0 1 24,14.45 9.5500002,9.5500002 0 0 1 33.55,24 Z" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2269.159)"
|
||||
id="g66">
|
||||
<path
|
||||
id="ellipse62"
|
||||
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
|
||||
d="m 17.75,1016.487 a 3.375,3.875 0 0 1 -3.375,3.875 3.375,3.875 0 0 1 -3.375,-3.875 3.375,3.875 0 0 1 3.375,-3.875 3.375,3.875 0 0 1 3.375,3.875 z" />
|
||||
<path
|
||||
id="circle64"
|
||||
style="opacity:1;fill:#ffffff;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
|
||||
d="m 17.75,1016.987 a 3.375,3.375 0 0 1 -3.375,3.375 3.375,3.375 0 0 1 -3.375,-3.375 3.375,3.375 0 0 1 3.375,-3.375 3.375,3.375 0 0 1 3.375,3.375 z" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,408.158,-2269.159)"
|
||||
id="g72">
|
||||
<path
|
||||
id="ellipse68"
|
||||
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
|
||||
d="m 17.75,1016.487 a 3.375,3.875 0 0 1 -3.375,3.875 3.375,3.875 0 0 1 -3.375,-3.875 3.375,3.875 0 0 1 3.375,-3.875 3.375,3.875 0 0 1 3.375,3.875 z" />
|
||||
<path
|
||||
id="circle70"
|
||||
style="opacity:1;fill:#ffffff;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
|
||||
d="m 17.75,1016.987 a 3.375,3.375 0 0 1 -3.375,3.375 3.375,3.375 0 0 1 -3.375,-3.375 3.375,3.375 0 0 1 3.375,-3.375 3.375,3.375 0 0 1 3.375,3.375 z" />
|
||||
</g>
|
||||
<path
|
||||
d="m 282.715,299.835 a 3.29,3.29 0 0 0 -2.662,5.336 l 9.474,12.261 A 7.9,7.9 0 0 0 289,320.257 v 18.21 a 7.877,7.877 0 0 0 7.895,7.895 h 84.21 A 7.877,7.877 0 0 0 389,338.468 v -18.211 c 0,-0.999 -0.19,-1.949 -0.525,-2.826 l 9.472,-12.26 a 3.29,3.29 0 0 0 -2.433,-5.334 3.29,3.29 0 0 0 -2.772,1.31 l -9.013,11.666 a 7.9,7.9 0 0 0 -2.624,-0.45 h -84.21 c -0.922,0 -1.8,0.163 -2.622,0.45 l -9.015,-11.666 a 3.29,3.29 0 0 0 -2.543,-1.312 m 14.18,49.527 A 7.877,7.877 0 0 0 289,357.257 v 52.21 a 7.877,7.877 0 0 0 7.895,7.895 h 84.21 A 7.877,7.877 0 0 0 389,409.468 v -52.211 a 7.877,7.877 0 0 0 -7.895,-7.895 z"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#b);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:6.57895;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="translate(81,76)"
|
||||
id="path74" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -5,7 +5,7 @@ on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https
|
||||
contribute, or [build your own](../develop.md).
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="../../static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.svg"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="../../static/img/badge-appstore.png"></a>
|
||||
|
||||
You can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
|
||||
@@ -82,9 +82,8 @@ you'll see as a permanent notification that looks like this:
|
||||
<figcaption>Instant delivery foreground notification</figcaption>
|
||||
</figure>
|
||||
|
||||
Android does not allow you to dismiss this notification, unless you turn off the notification channel in the settings.
|
||||
To do so, long-press on the foreground notification (screenshot above) and navigate to the settings. Then toggle the
|
||||
"Subscription Service" off:
|
||||
To turn off this notification, long-press on the foreground notification (screenshot above) and navigate to the
|
||||
settings. Then toggle the "Subscription Service" off:
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
@@ -102,6 +101,28 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
|
||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor.
|
||||
|
||||
!!! info "F-Droid: Always instant delivery"
|
||||
Since the F-Droid build does not include Firebase, **all subscriptions use instant delivery by default**, and
|
||||
there is no option to disable it. The F-Droid app hides all mentions of "instant delivery" in the UI, since
|
||||
showing options that can't be changed would only be confusing.
|
||||
|
||||
## Publishing messages
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
The Android app allows you to **publish messages directly from the app**, without needing to use curl or any other
|
||||
tool. When enabled in the settings (Settings → General → Show message bar), a **message bar** appears at the bottom
|
||||
of the topic view (it's enabled by default). You can type a message and tap the send button to publish it instantly.
|
||||
If the message bar is disabled, you can tap the floating action button (FAB) at the bottom right instead.
|
||||
|
||||
For more options, tap the expand button next to the send button to open the full **publish dialog**. The dialog lets
|
||||
you compose a full notification with all available options, including title, tags, priority, click URL, email
|
||||
forwarding, delayed delivery, attachments, Markdown formatting, and phone calls.
|
||||
|
||||
<div id="publish-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
## Share to topic
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
@@ -135,6 +156,67 @@ or to simply directly link to a topic from a mobile website.
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?display=<name>`</span> | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
|
||||
## Advanced settings
|
||||
|
||||
### Custom headers
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If your ntfy server is behind an **authenticated proxy or tunnel** (e.g., Cloudflare Access, Tailscale Funnel, or
|
||||
a reverse proxy with basic auth), you can configure custom HTTP headers that will be sent with every request to
|
||||
that server. You could set headers such as `Authorization`, `CF-Access-Client-Id`, or any other headers required by
|
||||
your setup. To add custom headers, go to **Settings → Advanced → Custom headers**.
|
||||
|
||||
<div id="custom-headers-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-custom-headers-add.jpg"><img src="../../static/img/android-screenshot-custom-headers-add.jpg"/></a>
|
||||
</div>
|
||||
|
||||
!!! warning
|
||||
If you have a user configured for a server, you cannot add an `Authorization` header for that server, as ntfy
|
||||
sets this header automatically. Similarly, if you have a custom `Authorization` header, you cannot add a user
|
||||
for that server.
|
||||
|
||||
### Manage certificates
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If you're running a self-hosted ntfy server with a **self-signed certificate** or need to use **mutual TLS (mTLS)**
|
||||
for client authentication, you can manage certificates in the app settings.
|
||||
|
||||
Go to **Settings → Advanced → Manage certificates** to:
|
||||
|
||||
- **Add trusted certificates**: Import a server certificate (PEM format) to trust when connecting to your ntfy server.
|
||||
This is useful for self-signed certificates that are not trusted by the Android system.
|
||||
- **Add client certificates**: Import a client certificate (PKCS#12 format) for mutual TLS authentication. This
|
||||
certificate will be presented to the server when connecting.
|
||||
|
||||
When you subscribe to a topic on a server with an untrusted certificate, the app will show a security warning and
|
||||
allow you to review and trust the certificate.
|
||||
|
||||
<div id="certificates-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
### Language
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
The Android app supports many languages and uses the **system language by default**. If you'd like to use the app in
|
||||
a different language than your system, you can override it in **Settings → General → Language**.
|
||||
|
||||
<div id="language-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-german.jpg"><img src="../../static/img/android-screenshot-language-german.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-hebrew.jpg"><img src="../../static/img/android-screenshot-language-hebrew.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-chinese.jpg"><img src="../../static/img/android-screenshot-language-chinese.jpg"/></a>
|
||||
</div>
|
||||
|
||||
The app currently supports over 30 languages, including English, German, French, Spanish, Chinese, Japanese, and many
|
||||
more. Languages with more than 80% of strings translated are shown in the language picker.
|
||||
|
||||
!!! tip "Help translate ntfy"
|
||||
If you'd like to help translate ntfy into your language or improve existing translations, please visit the
|
||||
[ntfy Weblate project](https://hosted.weblate.org/projects/ntfy/). Contributions are very welcome!
|
||||
|
||||
## Integrations
|
||||
|
||||
### UnifiedPush
|
||||
@@ -168,10 +250,13 @@ Here's an example using [MacroDroid](https://play.google.com/store/apps/details?
|
||||
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch
|
||||
broadcasts is supported:
|
||||
|
||||
<div id="integration-screenshots-receive" class="screenshots">
|
||||
<div id="integration-screenshots-receive-1" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-macrodroid-overview.png"><img src="../../static/img/android-screenshot-macrodroid-overview.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-macrodroid-trigger.png"><img src="../../static/img/android-screenshot-macrodroid-trigger.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-macrodroid-action.png"><img src="../../static/img/android-screenshot-macrodroid-action.png"/></a>
|
||||
</div>
|
||||
|
||||
<div id="integration-screenshots-receive-2" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-tasker-profiles.png"><img src="../../static/img/android-screenshot-tasker-profiles.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-event-edit.png"><img src="../../static/img/android-screenshot-tasker-event-edit.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-task-edit.png"><img src="../../static/img/android-screenshot-tasker-task-edit.png"/></a>
|
||||
@@ -239,3 +324,29 @@ The following intent extras are supported when for the intent with the `io.hecke
|
||||
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection error dialog
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If the app has trouble connecting to a ntfy server, a **warning icon** will appear in the app bar. Tapping it opens
|
||||
the **connection error dialog**, which shows detailed information about the connection problem and helps you diagnose
|
||||
the issue.
|
||||
|
||||
<div id="connection-error-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-connection-error-warning.jpg"><img src="../../static/img/android-screenshot-connection-error-warning.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
Common connection errors include:
|
||||
|
||||
| Error | Description |
|
||||
|-------|-------------|
|
||||
| Connection refused | The server may be down or the address may be incorrect |
|
||||
| WebSocket not supported | The server may not support WebSocket connections, or a proxy is blocking them |
|
||||
| Not authorized (401/403) | Username/password may be incorrect, or access credentials have expired |
|
||||
| Certificate not trusted | The server is using a self-signed certificate (see [Manage certificates](#manage-certificates)) |
|
||||
|
||||
If you're having persistent connection issues, you can also check the app logs under **Settings → Advanced → Record logs**
|
||||
and share them for debugging.
|
||||
|
||||
257
docs/terms.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Terms of Service
|
||||
|
||||
**Last updated:** January 26, 2026
|
||||
|
||||
Please read these Terms of Service ("Terms") carefully before using the ntfy.sh website and service (the "Service")
|
||||
operated by ntfy LLC ("us", "we", or "our").
|
||||
|
||||
Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These
|
||||
Terms apply to all visitors, users, and others who access or use the Service.
|
||||
|
||||
**By accessing or using the Service, you agree to be bound by these Terms. If you disagree with any part of the
|
||||
Terms, you may not access the Service.**
|
||||
|
||||
## Service description
|
||||
|
||||
ntfy (pronounced "notify") is a simple HTTP-based pub-sub notification service. It allows you to send push
|
||||
notifications to your phone or desktop via scripts from any computer, using a REST API. The Service includes:
|
||||
|
||||
- The ntfy.sh hosted server
|
||||
- The ntfy web application
|
||||
- The ntfy mobile applications (Android and iOS)
|
||||
- The ntfy command-line interface (CLI)
|
||||
|
||||
The server software and mobile applications are open source and can be [self-hosted](install.md). These Terms
|
||||
apply specifically to the ntfy.sh hosted service.
|
||||
|
||||
## Subscriptions and billing
|
||||
|
||||
### Free tier
|
||||
|
||||
You may use the Service without creating an account or subscribing to a paid plan. Free usage is subject to
|
||||
rate limits and other restrictions as described in our documentation.
|
||||
|
||||
### Paid plans
|
||||
|
||||
Some features of the Service are available only through paid subscription plans ("Subscriptions"). You will
|
||||
be billed in advance on a recurring basis ("Billing Cycle"). Billing cycles are available on a monthly or
|
||||
annual basis.
|
||||
|
||||
At the end of each Billing Cycle, your Subscription will automatically renew under the same conditions unless
|
||||
you cancel it or we cancel it. You may cancel your Subscription renewal through your account settings in the
|
||||
web application.
|
||||
|
||||
A valid payment method is required to process payment for your Subscription. You shall provide us with accurate
|
||||
and complete billing information. By submitting such payment information, you authorize us to charge all
|
||||
Subscription fees incurred through your account to your payment method.
|
||||
|
||||
Payment processing is handled by Stripe. Your payment information is subject to Stripe's
|
||||
[privacy policy](https://stripe.com/privacy) and [terms of service](https://stripe.com/legal).
|
||||
|
||||
Should automatic billing fail to occur for any reason, we will retry the payment according to Stripe's retry
|
||||
schedule. If payment continues to fail after multiple attempts, your Subscription will be canceled and your
|
||||
account will revert to the free tier.
|
||||
|
||||
### Fee changes
|
||||
|
||||
We may, in our sole discretion and at any time, modify the Subscription fees for paid plans. Any fee change
|
||||
will become effective at the end of the then-current Billing Cycle.
|
||||
|
||||
We will provide you with reasonable prior notice of any change in Subscription fees to give you an opportunity
|
||||
to cancel your Subscription before such change becomes effective.
|
||||
|
||||
Your continued use of the Service after a fee change comes into effect constitutes your agreement to pay the
|
||||
modified Subscription fee.
|
||||
|
||||
## Refunds
|
||||
|
||||
Refund requests for Subscriptions may be considered on a case-by-case basis and granted at the sole discretion
|
||||
of ntfy LLC. To request a refund, please contact us at [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh).
|
||||
|
||||
## User accounts
|
||||
|
||||
When you create an account with us, you must provide information that is accurate, complete, and current at
|
||||
all times. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of
|
||||
your account.
|
||||
|
||||
You are responsible for:
|
||||
|
||||
- Safeguarding the password that you use to access the Service
|
||||
- Any activities or actions under your account, whether your password is with our Service or a third-party service
|
||||
- Keeping your account credentials confidential
|
||||
|
||||
You agree not to disclose your password to any third party. You must notify us immediately upon becoming aware
|
||||
of any breach of security or unauthorized use of your account.
|
||||
|
||||
You represent that you are at least 18 years old, or that you are at least the minimum age required to form
|
||||
a binding contract in your jurisdiction, and have the legal authority to enter into these Terms.
|
||||
|
||||
## Acceptable use
|
||||
|
||||
You agree not to use the Service to:
|
||||
|
||||
- Send spam, unsolicited messages, or messages to recipients who have not consented to receive them
|
||||
- Distribute malware, viruses, or any other malicious software
|
||||
- Transmit illegal content or content that violates the rights of others
|
||||
- Harass, abuse, or harm another person or group
|
||||
- Impersonate any person or entity, or falsely state or misrepresent your affiliation with a person or entity
|
||||
- Interfere with or disrupt the Service or servers or networks connected to the Service
|
||||
- Attempt to gain unauthorized access to the Service, other accounts, or computer systems
|
||||
- Use the Service for any illegal purpose or in violation of any applicable laws or regulations
|
||||
- Circumvent rate limits or other technical restrictions
|
||||
- Use the Service in a manner that could reasonably be expected to impose an unreasonable or disproportionately
|
||||
large load on our infrastructure
|
||||
|
||||
We reserve the right to investigate and take appropriate action against anyone who, in our sole discretion,
|
||||
violates this provision, including removing content, terminating accounts, and reporting to law enforcement.
|
||||
|
||||
### Topic names
|
||||
|
||||
Topic names on ntfy.sh are public. If you use the Service without access controls, your topic name functions
|
||||
as a password. You are responsible for choosing topic names that cannot be easily guessed. We are not responsible
|
||||
for any unauthorized access to messages published to easily guessable topic names.
|
||||
|
||||
For reserved topics and access control features, consider subscribing to a paid plan.
|
||||
|
||||
## Intellectual property
|
||||
|
||||
### Open source software
|
||||
|
||||
The ntfy server, web application, and mobile applications are open source software, dual-licensed under the
|
||||
[Apache License 2.0](https://github.com/binwiederhier/ntfy/blob/main/LICENSE) and
|
||||
[GPLv2](https://github.com/binwiederhier/ntfy/blob/main/LICENSE.GPLv2). You are free to use, modify, and
|
||||
distribute the software in accordance with these licenses.
|
||||
|
||||
### Trademarks
|
||||
|
||||
The ntfy name, logo, and branding are trademarks of ntfy LLC. Our trademarks may not be used in connection
|
||||
with any product or service without our prior written consent.
|
||||
|
||||
### Your content
|
||||
|
||||
You retain ownership of any content you transmit through the Service. By using the Service, you grant us a
|
||||
limited license to process and transmit your content solely for the purpose of providing the Service.
|
||||
|
||||
## Service availability
|
||||
|
||||
The Service is provided on a "best effort" basis. We do not guarantee any specific uptime or availability.
|
||||
|
||||
We strive to maintain high availability, but the Service may be interrupted for maintenance, updates, or
|
||||
due to circumstances beyond our control. We will make reasonable efforts to notify users of planned
|
||||
maintenance when possible.
|
||||
|
||||
For applications requiring guaranteed uptime or specific service level agreements, we recommend
|
||||
[self-hosting your own ntfy server](install.md).
|
||||
|
||||
A [status page](https://ntfy.statuspage.io/) is available to check the current operational status of the Service.
|
||||
|
||||
## Third-party services
|
||||
|
||||
The Service relies on third-party services to provide certain functionality:
|
||||
|
||||
- **Firebase Cloud Messaging (FCM)** - For push notifications to Android and iOS devices
|
||||
- **Twilio** - For phone call notifications
|
||||
- **Amazon SES** - For email notifications
|
||||
- **Stripe** - For payment processing
|
||||
|
||||
Your use of these features is subject to the respective third-party terms and privacy policies. For more
|
||||
details, see our [privacy policy](privacy.md).
|
||||
|
||||
## Links to other websites
|
||||
|
||||
Our Service may contain links to third-party websites or services that are not owned or controlled by us.
|
||||
|
||||
We have no control over, and assume no responsibility for, the content, privacy policies, or practices of
|
||||
any third-party websites or services. You acknowledge and agree that we shall not be responsible or liable,
|
||||
directly or indirectly, for any damage or loss caused by or in connection with the use of any such content,
|
||||
goods, or services available through any such websites or services.
|
||||
|
||||
## Termination
|
||||
|
||||
We may terminate or suspend your account immediately, without prior notice or liability, for any reason
|
||||
whatsoever, including without limitation if you breach these Terms.
|
||||
|
||||
Upon termination, your right to use the Service will immediately cease. If you wish to terminate your account,
|
||||
you may do so through your account settings or by simply discontinuing use of the Service.
|
||||
|
||||
Termination of your account will result in the deletion of your account data in accordance with our
|
||||
[privacy policy](privacy.md).
|
||||
|
||||
We may retain certain data as required to comply with legal obligations, resolve disputes, and enforce our
|
||||
agreements, as described in our privacy policy.
|
||||
|
||||
## Limitation of liability
|
||||
|
||||
In no event shall ntfy LLC, nor its owner, employees, partners, agents, suppliers, or affiliates, be liable
|
||||
for any indirect, incidental, special, consequential, or punitive damages, including without limitation:
|
||||
|
||||
- Loss of profits, data, use, goodwill, or other intangible losses
|
||||
- Damages resulting from your access to, use of, or inability to access or use the Service
|
||||
- Damages resulting from any conduct or content of any third party on the Service
|
||||
- Damages resulting from any content obtained from the Service
|
||||
- Damages resulting from unauthorized access, use, or alteration of your transmissions or content
|
||||
|
||||
This limitation applies whether based on warranty, contract, tort (including negligence), or any other legal
|
||||
theory, whether or not we have been informed of the possibility of such damage, and even if a remedy set
|
||||
forth herein is found to have failed of its essential purpose.
|
||||
|
||||
## Indemnification
|
||||
|
||||
You agree to defend, indemnify, and hold harmless ntfy LLC and its owner, employees, partners, agents, suppliers,
|
||||
and affiliates from and against any claims, damages, obligations, losses, liabilities, costs, or debt, and
|
||||
expenses (including but not limited to attorney's fees) arising from:
|
||||
|
||||
- Your use of and access to the Service
|
||||
- Your violation of any term of these Terms
|
||||
- Your violation of any applicable law or regulation
|
||||
- Your content, including any claim that your content infringes or misappropriates the rights of any third party
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Your use of the Service is at your sole risk. The Service is provided on an "AS IS" and "AS AVAILABLE" basis,
|
||||
without warranties of any kind, whether express or implied, including but not limited to implied warranties of
|
||||
merchantability, fitness for a particular purpose, non-infringement, or course of performance.
|
||||
|
||||
ntfy LLC does not warrant that:
|
||||
|
||||
- The Service will function uninterrupted, secure, or available at any particular time or location
|
||||
- Any errors or defects will be corrected
|
||||
- The Service is free of viruses or other harmful components
|
||||
- The results of using the Service will meet your requirements
|
||||
- Messages will be delivered successfully or in a timely manner
|
||||
|
||||
If your use case requires guaranteed message delivery, high availability, or handling of sensitive data, we
|
||||
strongly recommend [self-hosting your own ntfy server](install.md) where you have full control over the
|
||||
infrastructure and data.
|
||||
|
||||
## Governing law
|
||||
|
||||
These Terms shall be governed and construed in accordance with the laws of the State of Connecticut, United States,
|
||||
without regard to its conflict of law provisions.
|
||||
|
||||
Any legal action or proceeding arising under these Terms shall be brought exclusively in the federal or state
|
||||
courts located in Connecticut, and the parties hereby consent to personal jurisdiction and venue therein.
|
||||
|
||||
Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights.
|
||||
If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions
|
||||
of these Terms will remain in effect.
|
||||
|
||||
These Terms constitute the entire agreement between us regarding our Service and supersede any prior agreements
|
||||
we might have had regarding the Service.
|
||||
|
||||
## Changes to these Terms
|
||||
|
||||
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is
|
||||
material, we will try to provide at least 30 days' notice prior to any new terms taking effect.
|
||||
|
||||
What constitutes a material change will be determined at our sole discretion. Changes will be posted on this
|
||||
page with an updated "Last updated" date. You may also review all changes in the
|
||||
[Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/terms.md).
|
||||
|
||||
By continuing to access or use our Service after those revisions become effective, you agree to be bound by
|
||||
the revised Terms. If you do not agree to the new Terms, please stop using the Service.
|
||||
|
||||
## Contact
|
||||
|
||||
If you have any questions about these Terms, please see our [contact page](contact.md) or email us at
|
||||
[support@mail.ntfy.sh](mailto:support@mail.ntfy.sh).
|
||||
52
go.mod
@@ -1,16 +1,14 @@
|
||||
module heckel.io/ntfy/v2
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.5
|
||||
go 1.24.6
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.21.0 // indirect
|
||||
cloud.google.com/go/storage v1.59.1 // indirect
|
||||
cloud.google.com/go/storage v1.59.2 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.18.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.12
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/olebedev/when v1.1.0
|
||||
@@ -21,7 +19,7 @@ require (
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.260.0
|
||||
google.golang.org/api v0.265.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -30,8 +28,9 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
|
||||
require github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.18.0
|
||||
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
|
||||
@@ -42,21 +41,21 @@ require (
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/longrunning v0.8.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
@@ -66,13 +65,16 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 // 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
|
||||
@@ -84,20 +86,20 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
101
go.sum
@@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
|
||||
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
|
||||
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
@@ -18,24 +18,24 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58=
|
||||
cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw=
|
||||
cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
|
||||
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
@@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -68,8 +68,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -81,8 +81,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
@@ -98,12 +98,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/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=
|
||||
@@ -156,24 +165,24 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
@@ -263,16 +272,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4=
|
||||
google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o=
|
||||
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
|
||||
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE=
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20 h1:/CU1zrxTpGylJJbe3Ru94yy6sZRbzALq2/oxl3pGB3U=
|
||||
google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20/go.mod h1:Tt+08/KdKEt3l8x3Pby3HLQxMB3uk/MzaQ4ZIv0ORTs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
|
||||
@@ -101,6 +101,7 @@ nav:
|
||||
- "Development": develop.md
|
||||
- "Contributing": contributing.md
|
||||
- "Privacy policy": privacy.md
|
||||
- "Terms of Service": terms.md
|
||||
- "Contact": contact.md
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,12 +21,14 @@ const (
|
||||
actionView = "view"
|
||||
actionBroadcast = "broadcast"
|
||||
actionHTTP = "http"
|
||||
actionCopy = "copy"
|
||||
)
|
||||
|
||||
var (
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
||||
actionsWithURL = []string{actionView, actionHTTP}
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
|
||||
actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
|
||||
actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
)
|
||||
|
||||
type actionParser struct {
|
||||
@@ -61,11 +64,13 @@ func parseActions(s string) (actions []*action, err error) {
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.Contains(actionsAll, action.Action) {
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, fmt.Errorf("parameter 'label' is required")
|
||||
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
|
||||
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
|
||||
return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
|
||||
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
@@ -158,6 +163,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
key = "label"
|
||||
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
|
||||
key = "url"
|
||||
} else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
|
||||
key = "value"
|
||||
}
|
||||
|
||||
// Validate
|
||||
@@ -188,6 +195,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
case "value":
|
||||
newAction.Value = value
|
||||
case "intent":
|
||||
newAction.Intent = value
|
||||
default:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
@@ -132,6 +133,44 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||
require.Equal(t, true, actions[1].Clear)
|
||||
|
||||
// Copy action (simple format)
|
||||
actions, err = parseActions("copy, Copy code, 1234")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy code", actions[0].Label)
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
|
||||
// Copy action (JSON)
|
||||
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy OTP", actions[0].Label)
|
||||
require.Equal(t, "567890", actions[0].Value)
|
||||
|
||||
// Copy action with clear
|
||||
actions, err = parseActions("copy, Copy code, 1234, clear=true")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy code", actions[0].Label)
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
require.Equal(t, true, actions[0].Clear)
|
||||
|
||||
// Copy action with explicit value key
|
||||
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy token", actions[0].Label)
|
||||
require.Equal(t, "abc-123-def", actions[0].Value)
|
||||
require.True(t, actions[0].Clear)
|
||||
|
||||
// Copy action without value (error)
|
||||
_, err = parseActions("copy, Copy code")
|
||||
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
|
||||
|
||||
// Invalid syntax
|
||||
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||
@@ -146,7 +185,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||
|
||||
_, err = parseActions(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
require.EqualError(t, err, "key 'aaa' unknown")
|
||||
@@ -173,7 +212,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||
|
||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||
require.EqualError(t, err, "invalid utf-8 string")
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -78,6 +78,21 @@ func (e errHTTP) clone() errHTTP {
|
||||
}
|
||||
}
|
||||
|
||||
// errWebSocketPostUpgrade is a wrapper error indicating an error occurred after the WebSocket
|
||||
// upgrade completed (i.e., the connection was hijacked). This is used to avoid calling
|
||||
// WriteHeader on hijacked connections, which causes log spam.
|
||||
type errWebSocketPostUpgrade struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errWebSocketPostUpgrade) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *errWebSocketPostUpgrade) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
var (
|
||||
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
|
||||
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Log tags
|
||||
@@ -83,7 +85,8 @@ func httpContext(r *http.Request) log.Context {
|
||||
}
|
||||
|
||||
func websocketErrorContext(err error) log.Context {
|
||||
if c, ok := err.(*websocket.CloseError); ok {
|
||||
var c *websocket.CloseError
|
||||
if errors.As(err, &c) {
|
||||
return log.Context{
|
||||
"error": c.Error(),
|
||||
"error_code": c.Code,
|
||||
|
||||
152
server/server.go
@@ -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!)
|
||||
@@ -90,6 +91,7 @@ var (
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
apiHealthPath = "/v1/health"
|
||||
apiVersionPath = "/v1/version"
|
||||
apiConfigPath = "/v1/config"
|
||||
apiStatsPath = "/v1/stats"
|
||||
apiWebPushPath = "/v1/webpush"
|
||||
@@ -175,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
|
||||
}
|
||||
@@ -198,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
|
||||
@@ -210,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
|
||||
}
|
||||
@@ -232,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,
|
||||
@@ -434,8 +450,14 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
} else {
|
||||
ev.Info("WebSocket error: %s", err.Error())
|
||||
}
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
return // Do not attempt to write any body to upgraded connection
|
||||
// Write error response only if the connection was not hijacked yet. Bytes written to hijacked
|
||||
// connections are WebSocket frames, not HTTP, and will cause "http: response.WriteHeader on hijacked
|
||||
// connection" log spam.
|
||||
var postUpgradeErr *errWebSocketPostUpgrade
|
||||
if !errors.As(err, &postUpgradeErr) {
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
if isNormalError {
|
||||
ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
@@ -461,6 +483,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||
return s.handleHealth(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath {
|
||||
return s.ensureAdmin(s.handleVersion)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
||||
return s.handleConfig(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
@@ -641,7 +665,7 @@ func (s *Server) configResponse() *apiConfigResponse {
|
||||
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
|
||||
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &webManifestResponse{
|
||||
Name: "ntfy web",
|
||||
Name: "ntfy",
|
||||
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
|
||||
ShortName: "ntfy",
|
||||
Scope: "/",
|
||||
@@ -787,7 +811,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||
return nil, err
|
||||
}
|
||||
m := newDefaultMessage(t.ID, "")
|
||||
cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m)
|
||||
cache, firebase, email, call, template, unifiedpush, priorityStr, e := s.parsePublishParams(r, m)
|
||||
if e != nil {
|
||||
return nil, e.With(t)
|
||||
}
|
||||
@@ -818,7 +842,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||
if cache {
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
}
|
||||
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil {
|
||||
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush, priorityStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Message == "" {
|
||||
@@ -1049,11 +1073,11 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, priorityStr string, err *errHTTP) {
|
||||
if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
|
||||
pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return false, false, "", "", "", false, err
|
||||
return false, false, "", "", "", false, "", err
|
||||
}
|
||||
m.SequenceID = pathSequenceID
|
||||
} else {
|
||||
@@ -1062,7 +1086,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
if sequenceIDRegex.MatchString(sequenceID) {
|
||||
m.SequenceID = sequenceID
|
||||
} else {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestSequenceIDInvalid
|
||||
}
|
||||
} else {
|
||||
m.SequenceID = m.ID
|
||||
@@ -1083,7 +1107,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if attach != "" {
|
||||
if !urlRegex.MatchString(attach) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestAttachmentURLInvalid
|
||||
}
|
||||
m.Attachment.URL = attach
|
||||
if m.Attachment.Name == "" {
|
||||
@@ -1101,19 +1125,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if icon != "" {
|
||||
if !urlRegex.MatchString(icon) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestIconURLInvalid
|
||||
}
|
||||
m.Icon = icon
|
||||
}
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
call = readParam(r, "x-call", "call")
|
||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestPhoneCallsDisabled
|
||||
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
template = templateMode(readParam(r, "x-template", "template", "tpl"))
|
||||
messageStr := readParam(r, "x-message", "message", "m")
|
||||
@@ -1125,29 +1149,33 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
m.Message = messageStr
|
||||
}
|
||||
var e error
|
||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if e != nil {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
|
||||
priorityStr = readParam(r, "x-priority", "priority", "prio", "p")
|
||||
if !template.Enabled() {
|
||||
m.Priority, e = util.ParsePriority(priorityStr)
|
||||
if e != nil {
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
priorityStr = "" // Clear since it's already parsed
|
||||
}
|
||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCache
|
||||
}
|
||||
if email != "" {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
}
|
||||
if call != "" {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||
}
|
||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||
if err != nil {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayCannotParse
|
||||
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayTooSmall
|
||||
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayTooLarge
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
}
|
||||
@@ -1155,7 +1183,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
if actionsStr != "" {
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
if e != nil {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
||||
}
|
||||
}
|
||||
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||
@@ -1174,7 +1202,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
cache = false
|
||||
email = ""
|
||||
}
|
||||
return cache, firebase, email, call, template, unifiedpush, nil
|
||||
return cache, firebase, email, call, template, unifiedpush, priorityStr, nil
|
||||
}
|
||||
|
||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||
@@ -1193,7 +1221,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool) error {
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error {
|
||||
if m.Event == pollRequestEvent { // Case 1
|
||||
return s.handleBodyDiscard(body)
|
||||
} else if unifiedpush {
|
||||
@@ -1203,7 +1231,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
|
||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||
} else if template.Enabled() {
|
||||
return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
|
||||
return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||
}
|
||||
@@ -1239,7 +1267,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error {
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
|
||||
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1252,7 +1280,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.renderTemplateFromParams(m, peekedBody); err != nil {
|
||||
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1283,33 +1311,51 @@ func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody str
|
||||
}
|
||||
var err error
|
||||
if tpl.Message != nil {
|
||||
if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil {
|
||||
if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Title != nil {
|
||||
if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil {
|
||||
if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Priority != nil {
|
||||
renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
|
||||
return errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
|
||||
// message and title parameters.
|
||||
func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error {
|
||||
// message, title, and priority parameters.
|
||||
func (s *Server) renderTemplateFromParams(m *message, peekedBody string, priorityStr string) error {
|
||||
var err error
|
||||
if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil {
|
||||
if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil {
|
||||
if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if priorityStr != "" {
|
||||
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
|
||||
return errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplate renders a template with the given JSON source data.
|
||||
func (s *Server) renderTemplate(tpl string, source string) (string, error) {
|
||||
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
|
||||
if templateDisallowedRegex.MatchString(tpl) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
@@ -1324,7 +1370,7 @@ func (s *Server) renderTemplate(tpl string, source string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
|
||||
if err := t.Execute(limitWriter, data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
||||
}
|
||||
@@ -1430,12 +1476,16 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
return err
|
||||
}
|
||||
var wlock sync.Mutex
|
||||
var closed bool
|
||||
defer func() {
|
||||
// Hack: This is the fix for a horrible data race that I have not been able to figure out in quite some time.
|
||||
// It appears to be happening when the Go HTTP code reads from the socket when closing the request (i.e. AFTER
|
||||
// this function returns), and causes a data race with the ResponseWriter. Locking wlock here silences the
|
||||
// data race detector. See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889.
|
||||
wlock.TryLock()
|
||||
// This blocks until any in-flight sub() call finishes writing/flushing the response writer,
|
||||
// then marks the connection as closed so future sub() calls are no-ops. This prevents a panic
|
||||
// from writing to a response writer that has been cleaned up after the handler returns.
|
||||
// See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889
|
||||
// and https://github.com/binwiederhier/ntfy/pull/1598.
|
||||
wlock.Lock()
|
||||
closed = true
|
||||
wlock.Unlock()
|
||||
}()
|
||||
sub := func(v *visitor, msg *message) error {
|
||||
if !filters.Pass(msg) {
|
||||
@@ -1447,6 +1497,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
wlock.Lock()
|
||||
defer wlock.Unlock()
|
||||
if closed {
|
||||
return nil
|
||||
}
|
||||
if _, err := w.Write([]byte(m)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1637,7 +1690,10 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Trace("WebSocket connection closed")
|
||||
return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return &errWebSocketPostUpgrade{err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
@@ -160,7 +166,7 @@
|
||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||
#
|
||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
|
||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTTLS are supported.
|
||||
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||
#
|
||||
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||
@@ -198,8 +204,8 @@
|
||||
# - 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-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-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)
|
||||
#
|
||||
# web-push-public-key:
|
||||
@@ -280,7 +286,7 @@
|
||||
#
|
||||
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
|
||||
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
|
||||
# if you exceed the upstream rate limits, or the uptream server requires authentication.
|
||||
# if you exceed the upstream rate limits, or the upstream server requires authentication.
|
||||
#
|
||||
# upstream-base-url:
|
||||
# upstream-access-token:
|
||||
|
||||
@@ -6,6 +6,14 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.writeJSON(w, &apiVersionResponse{
|
||||
Version: s.config.BuildVersion,
|
||||
Commit: s.config.BuildCommit,
|
||||
Date: s.config.BuildDate,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
users, err := s.userManager.Users()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -9,6 +10,41 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVersion_Admin(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.BuildVersion = "1.2.3"
|
||||
c.BuildCommit = "abcdef0"
|
||||
c.BuildDate = "2026-02-08T00:00:00Z"
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin and regular user
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Admin can access /v1/version
|
||||
rr := request(t, s, "GET", "/v1/version", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
var versionResponse apiVersionResponse
|
||||
require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse))
|
||||
require.Equal(t, "1.2.3", versionResponse.Version)
|
||||
require.Equal(t, "abcdef0", versionResponse.Commit)
|
||||
require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date)
|
||||
|
||||
// Non-admin user cannot access /v1/version
|
||||
rr = request(t, s, "GET", "/v1/version", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Unauthenticated user cannot access /v1/version
|
||||
rr = request(t, s, "GET", "/v1/version", "", nil)
|
||||
require.Equal(t, 401, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_AddRemove(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -3289,6 +3290,117 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) {
|
||||
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Priority(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"priority":"5"}`, map[string]string{
|
||||
"X-Message": "Test message",
|
||||
"X-Priority": "{{.priority}}",
|
||||
"X-Template": "1",
|
||||
})
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "Test message", m.Message)
|
||||
require.Equal(t, 5, m.Priority)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Priority_Conditional(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Test with error status -> priority 5
|
||||
response := request(t, s, "PUT", "/mytopic", `{"status":"Error","message":"Something went wrong"}`, map[string]string{
|
||||
"X-Message": "Status: {{.status}} - {{.message}}",
|
||||
"X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "Status: Error - Something went wrong", m.Message)
|
||||
require.Equal(t, 5, m.Priority)
|
||||
|
||||
// Test with success status -> priority 3
|
||||
response = request(t, s, "PUT", "/mytopic", `{"status":"Success","message":"All good"}`, map[string]string{
|
||||
"X-Message": "Status: {{.status}} - {{.message}}",
|
||||
"X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
m = toMessage(t, response.Body.String())
|
||||
require.Equal(t, "Status: Success - All good", m.Message)
|
||||
require.Equal(t, 3, m.Priority)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Priority_NamedValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"severity":"high"}`, map[string]string{
|
||||
"X-Message": "Alert",
|
||||
"X-Priority": "{{.severity}}",
|
||||
"X-Template": "1",
|
||||
})
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, 4, m.Priority) // "high" = 4
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Priority_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"priority":"invalid"}`, map[string]string{
|
||||
"X-Message": "Test message",
|
||||
"X-Priority": "{{.priority}}",
|
||||
"X-Template": "1",
|
||||
})
|
||||
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Priority_QueryParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?template=1&priority={{.priority}}", `{"priority":"max"}`, nil)
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, 5, m.Priority) // "max" = 5
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Priority_FromTemplateFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.TemplateDir = t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "priority-test.yml"), []byte(`
|
||||
title: "{{.title}}"
|
||||
message: "{{.message}}"
|
||||
priority: '{{if eq .level "critical"}}5{{else if eq .level "warning"}}4{{else}}3{{end}}'
|
||||
`), 0644))
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Test with critical level
|
||||
response := request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"System down","level":"critical"}`, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "Alert", m.Title)
|
||||
require.Equal(t, "System down", m.Message)
|
||||
require.Equal(t, 5, m.Priority)
|
||||
|
||||
// Test with warning level
|
||||
response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"High load","level":"warning"}`, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
m = toMessage(t, response.Body.String())
|
||||
require.Equal(t, 4, m.Priority)
|
||||
|
||||
// Test with info level
|
||||
response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"All good","level":"info"}`, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
m = toMessage(t, response.Body.String())
|
||||
require.Equal(t, 3, m.Priority)
|
||||
}
|
||||
|
||||
func TestServer_DeleteMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
@@ -3760,3 +3872,189 @@ func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) {
|
||||
}
|
||||
t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack()))
|
||||
}
|
||||
|
||||
// mockResponseWriter is a mock ResponseWriter for testing
|
||||
type mockResponseWriter struct {
|
||||
header http.Header
|
||||
statusCode int
|
||||
body []byte
|
||||
writeHeaderHit bool
|
||||
}
|
||||
|
||||
func newMockResponseWriter() *mockResponseWriter {
|
||||
return &mockResponseWriter{
|
||||
header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockResponseWriter) Header() http.Header {
|
||||
return m.header
|
||||
}
|
||||
|
||||
func (m *mockResponseWriter) Write(b []byte) (int, error) {
|
||||
m.body = append(m.body, b...)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (m *mockResponseWriter) WriteHeader(statusCode int) {
|
||||
m.statusCode = statusCode
|
||||
m.writeHeaderHit = true
|
||||
}
|
||||
|
||||
// closableResponseWriter simulates a real HTTP response writer that becomes invalid
|
||||
// after the handler returns. In production, Go's HTTP server calls finishRequest() after
|
||||
// the handler returns, which nils out the underlying bufio.Writer. Any subsequent Flush()
|
||||
// from a straggler Publish goroutine causes a nil pointer panic. This mock tracks whether
|
||||
// any Write or Flush occurred after the handler returned (i.e. after Close was called).
|
||||
type closableResponseWriter struct {
|
||||
header http.Header
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
wroteAfterClose atomic.Bool
|
||||
}
|
||||
|
||||
func newClosableResponseWriter() *closableResponseWriter {
|
||||
return &closableResponseWriter{
|
||||
header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *closableResponseWriter) Header() http.Header {
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *closableResponseWriter) Write(b []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.wroteAfterClose.Store(true)
|
||||
return 0, errors.New("write after handler returned")
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (w *closableResponseWriter) WriteHeader(statusCode int) {}
|
||||
|
||||
func (w *closableResponseWriter) Flush() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.wroteAfterClose.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Close simulates Go's HTTP server cleaning up the response writer after the handler returns.
|
||||
func (w *closableResponseWriter) Close() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.closed = true
|
||||
}
|
||||
|
||||
func TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) {
|
||||
// This test reproduces the panic from https://github.com/binwiederhier/ntfy/issues/338:
|
||||
//
|
||||
// panic: runtime error: invalid memory address or nil pointer dereference
|
||||
// bufio.(*Writer).Flush(...)
|
||||
// net/http.(*response).Flush(...)
|
||||
// server.(*Server).handleSubscribeHTTP.func2(...)
|
||||
// server.(*topic).Publish.func1.1(...)
|
||||
//
|
||||
// The race: topic.Publish() copies the subscriber list and calls each subscriber in its own
|
||||
// goroutine. If the subscriber disconnects, the handler returns and Go's HTTP server cleans up
|
||||
// the response writer. But a Publish goroutine that copied the subscriber list BEFORE
|
||||
// Unsubscribe may still call sub() AFTER the handler returns.
|
||||
//
|
||||
// This test deterministically reproduces the scenario by:
|
||||
// 1. Subscribing via handleSubscribeHTTP (which registers a sub closure on the topic)
|
||||
// 2. Copying the subscriber function from the topic (simulating what topic.Publish does)
|
||||
// 3. Cancelling the subscription and waiting for the handler to fully return
|
||||
// 4. Calling the copied subscriber function AFTER the handler has returned
|
||||
// 5. Checking that no write/flush occurred on the (now-invalid) response writer
|
||||
//
|
||||
// Without the wlock+closed fix, calling the subscriber after the handler returns writes to
|
||||
// the closed response writer (which in production causes a nil pointer panic on Flush).
|
||||
// With the fix, the subscriber sees closed=true and returns without writing.
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
rw := newClosableResponseWriter()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "/mytopic/json", nil)
|
||||
require.Nil(t, err)
|
||||
req.RemoteAddr = "9.9.9.9:1234"
|
||||
|
||||
// Start the subscribe handler (blocks until context is cancelled)
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
s.handle(rw, req)
|
||||
close(handlerDone)
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond) // Wait for subscription to be registered
|
||||
|
||||
// Grab a copy of the subscriber function from the topic, exactly as topic.Publish() does
|
||||
// via subscribersCopy(). This must happen BEFORE cancel/Unsubscribe removes the subscriber.
|
||||
s.mu.RLock()
|
||||
tp := s.topics["mytopic"]
|
||||
s.mu.RUnlock()
|
||||
require.NotNil(t, tp)
|
||||
subscribersCopy := tp.subscribersCopy()
|
||||
require.Equal(t, 1, len(subscribersCopy))
|
||||
|
||||
var copiedSub subscriber
|
||||
for _, sub := range subscribersCopy {
|
||||
copiedSub = sub.subscriber
|
||||
}
|
||||
|
||||
// Cancel the subscription and wait for the handler to fully return.
|
||||
// At this point, the deferred cleanup in handleSubscribeHTTP runs:
|
||||
// - With fix: wlock.Lock() waits for in-flight sub(), sets closed=true, wlock.Unlock()
|
||||
// - Without fix: nothing prevents future sub() calls from writing
|
||||
cancel()
|
||||
<-handlerDone
|
||||
|
||||
// Simulate Go's HTTP server cleaning up the response writer after the handler returns.
|
||||
// In production, this is finishRequest() which nils out the bufio.Writer.
|
||||
rw.Close()
|
||||
|
||||
// Now call the copied subscriber function, simulating a straggler Publish goroutine
|
||||
// that copied the subscriber list before Unsubscribe ran. In production, this is exactly
|
||||
// how the panic occurs: the goroutine spawned by topic.Publish calls sub() after the
|
||||
// handler has already returned and Go has cleaned up the response writer.
|
||||
v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("9.9.9.9"), nil)
|
||||
msg := newDefaultMessage("mytopic", "straggler message")
|
||||
_ = copiedSub(v, msg)
|
||||
|
||||
require.False(t, rw.wroteAfterClose.Load(),
|
||||
"sub() wrote to the response writer after the handler returned; "+
|
||||
"in production this causes a nil pointer panic in bufio.(*Writer).Flush()")
|
||||
}
|
||||
|
||||
func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) {
|
||||
// Test that handleError does not call WriteHeader for WebSocket errors wrapped
|
||||
// with errWebSocketPostUpgrade (indicating the connection was hijacked)
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Create a WebSocket upgrade request
|
||||
r, _ := http.NewRequest("GET", "/mytopic/ws", nil)
|
||||
r.Header.Set("Upgrade", "websocket")
|
||||
r.Header.Set("Connection", "Upgrade")
|
||||
v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
|
||||
// Test post-upgrade errors wrapped with errWebSocketPostUpgrade (should NOT call WriteHeader)
|
||||
postUpgradeErr := &errWebSocketPostUpgrade{errors.New("websocket: close 1000 (normal)")}
|
||||
mock := newMockResponseWriter()
|
||||
s.handleError(mock, r, v, postUpgradeErr)
|
||||
require.False(t, mock.writeHeaderHit, "WriteHeader should not be called for post-upgrade errors")
|
||||
|
||||
// Test pre-upgrade errors (should call WriteHeader)
|
||||
preUpgradeErrors := []error{
|
||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing,
|
||||
errHTTPTooManyRequestsLimitSubscriptions,
|
||||
errHTTPInternalError,
|
||||
}
|
||||
for _, err := range preUpgradeErrors {
|
||||
mock := newMockResponseWriter()
|
||||
s.handleError(mock, r, v, err)
|
||||
require.True(t, mock.writeHeaderHit, "WriteHeader should be called for error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -86,7 +86,7 @@ type attachment struct {
|
||||
|
||||
type action struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"` // "view", "broadcast", or "http"
|
||||
Action string `json:"action"` // "view", "broadcast", "http", or "copy"
|
||||
Label string `json:"label"` // action button label
|
||||
Clear bool `json:"clear"` // clear notification after successful execution
|
||||
URL string `json:"url,omitempty"` // used in "view" and "http" actions
|
||||
@@ -95,6 +95,7 @@ type action struct {
|
||||
Body string `json:"body,omitempty"` // used in "http" action
|
||||
Intent string `json:"intent,omitempty"` // used in "broadcast" action
|
||||
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||
Value string `json:"value,omitempty"` // used in "copy" action
|
||||
}
|
||||
|
||||
func newAction() *action {
|
||||
@@ -299,7 +300,7 @@ func (t templateMode) FileName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// templateFile represents a template file with title and message
|
||||
// templateFile represents a template file with title, message, and priority
|
||||
// It is used for file-based templates, e.g. grafana, influxdb, etc.
|
||||
//
|
||||
// Example YAML:
|
||||
@@ -308,15 +309,23 @@ func (t templateMode) FileName() string {
|
||||
// message: |
|
||||
// This is a {{ .Type }} alert.
|
||||
// It can be multiline.
|
||||
// priority: '{{ if eq .status "Error" }}5{{ else }}3{{ end }}'
|
||||
type templateFile struct {
|
||||
Title *string `yaml:"title"`
|
||||
Message *string `yaml:"message"`
|
||||
Title *string `yaml:"title"`
|
||||
Message *string `yaml:"message"`
|
||||
Priority *string `yaml:"priority"`
|
||||
}
|
||||
|
||||
type apiHealthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
type apiVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
type apiStatsResponse struct {
|
||||
Messages int64 `json:"messages"`
|
||||
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
||||
@@ -584,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"`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
27
tools/shrink-png.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Shrinks PNG files to a max height of 1200px
|
||||
# Usage: ./shrink-png.sh file1.png file2.png ...
|
||||
#
|
||||
|
||||
MAX_HEIGHT=1200
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 file1.png file2.png ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "$@"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "File not found: $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
height=$(identify -format "%h" "$file")
|
||||
if [ "$height" -gt "$MAX_HEIGHT" ]; then
|
||||
echo "Shrinking $file (${height}px -> ${MAX_HEIGHT}px)"
|
||||
convert "$file" -resize "x${MAX_HEIGHT}" "$file"
|
||||
else
|
||||
echo "Skipping $file (${height}px <= ${MAX_HEIGHT}px)"
|
||||
fi
|
||||
done
|
||||
1604
user/manager.go
2240
user/manager_test.go
986
user/store.go
Normal 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
@@ -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
|
||||
}
|
||||
113
user/store_postgres_schema.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
72
user/util.go
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -95,12 +96,7 @@ func coalesce(v ...any) any {
|
||||
// Returns:
|
||||
// - bool: True if all values are non-empty, false otherwise
|
||||
func all(v ...any) bool {
|
||||
for _, val := range v {
|
||||
if empty(val) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return !slices.ContainsFunc(v, empty)
|
||||
}
|
||||
|
||||
// anyNonEmpty checks if at least one value in a list is non-empty.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -49,12 +50,7 @@ func FileExists(filename string) bool {
|
||||
|
||||
// Contains returns true if needle is contained in haystack
|
||||
func Contains[T comparable](haystack []T, needle T) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(haystack, needle)
|
||||
}
|
||||
|
||||
// ContainsIP returns true if any one of the of prefixes contains the ip.
|
||||
|
||||
463
web/package-lock.json
generated
@@ -46,9 +46,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
||||
"integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
@@ -60,9 +60,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
|
||||
"integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -70,21 +70,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -108,13 +108,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
|
||||
"integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz",
|
||||
"integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -194,17 +194,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-define-polyfill-provider": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz",
|
||||
"integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==",
|
||||
"version": "0.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz",
|
||||
"integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-compilation-targets": "^7.27.2",
|
||||
"@babel/helper-plugin-utils": "^7.27.1",
|
||||
"debug": "^4.4.1",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-plugin-utils": "^7.28.6",
|
||||
"debug": "^4.4.3",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"resolve": "^1.22.10"
|
||||
"resolve": "^1.22.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
@@ -395,12 +395,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
|
||||
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -572,15 +572,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-async-generator-functions": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz",
|
||||
"integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz",
|
||||
"integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.28.6",
|
||||
"@babel/helper-remap-async-to-generator": "^7.27.1",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -762,9 +762,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz",
|
||||
"integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz",
|
||||
"integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -977,16 +977,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-modules-systemjs": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
|
||||
"integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
|
||||
"integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-transforms": "^7.28.3",
|
||||
"@babel/helper-plugin-utils": "^7.27.1",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helper-plugin-utils": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.5"
|
||||
"@babel/traverse": "^7.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1013,14 +1013,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz",
|
||||
"integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz",
|
||||
"integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
|
||||
"@babel/helper-plugin-utils": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1247,9 +1247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-regenerator": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz",
|
||||
"integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz",
|
||||
"integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1444,13 +1444,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/preset-env": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz",
|
||||
"integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz",
|
||||
"integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/compat-data": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-plugin-utils": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
@@ -1464,7 +1464,7 @@
|
||||
"@babel/plugin-syntax-import-attributes": "^7.28.6",
|
||||
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
|
||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-async-generator-functions": "^7.28.6",
|
||||
"@babel/plugin-transform-async-generator-functions": "^7.29.0",
|
||||
"@babel/plugin-transform-async-to-generator": "^7.28.6",
|
||||
"@babel/plugin-transform-block-scoped-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-block-scoping": "^7.28.6",
|
||||
@@ -1475,7 +1475,7 @@
|
||||
"@babel/plugin-transform-destructuring": "^7.28.5",
|
||||
"@babel/plugin-transform-dotall-regex": "^7.28.6",
|
||||
"@babel/plugin-transform-duplicate-keys": "^7.27.1",
|
||||
"@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6",
|
||||
"@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0",
|
||||
"@babel/plugin-transform-dynamic-import": "^7.27.1",
|
||||
"@babel/plugin-transform-explicit-resource-management": "^7.28.6",
|
||||
"@babel/plugin-transform-exponentiation-operator": "^7.28.6",
|
||||
@@ -1488,9 +1488,9 @@
|
||||
"@babel/plugin-transform-member-expression-literals": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-amd": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
|
||||
"@babel/plugin-transform-modules-systemjs": "^7.28.5",
|
||||
"@babel/plugin-transform-modules-systemjs": "^7.29.0",
|
||||
"@babel/plugin-transform-modules-umd": "^7.27.1",
|
||||
"@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1",
|
||||
"@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0",
|
||||
"@babel/plugin-transform-new-target": "^7.27.1",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
|
||||
"@babel/plugin-transform-numeric-separator": "^7.28.6",
|
||||
@@ -1502,7 +1502,7 @@
|
||||
"@babel/plugin-transform-private-methods": "^7.28.6",
|
||||
"@babel/plugin-transform-private-property-in-object": "^7.28.6",
|
||||
"@babel/plugin-transform-property-literals": "^7.27.1",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.6",
|
||||
"@babel/plugin-transform-regenerator": "^7.29.0",
|
||||
"@babel/plugin-transform-regexp-modifiers": "^7.28.6",
|
||||
"@babel/plugin-transform-reserved-words": "^7.27.1",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
||||
@@ -1515,10 +1515,10 @@
|
||||
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
||||
"@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
|
||||
"@babel/preset-modules": "0.1.6-no-external-plugins",
|
||||
"babel-plugin-polyfill-corejs2": "^0.4.14",
|
||||
"babel-plugin-polyfill-corejs3": "^0.13.0",
|
||||
"babel-plugin-polyfill-regenerator": "^0.6.5",
|
||||
"core-js-compat": "^3.43.0",
|
||||
"babel-plugin-polyfill-corejs2": "^0.4.15",
|
||||
"babel-plugin-polyfill-corejs3": "^0.14.0",
|
||||
"babel-plugin-polyfill-regenerator": "^0.6.6",
|
||||
"core-js-compat": "^3.48.0",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1567,17 +1567,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
|
||||
"integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1585,9 +1585,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
|
||||
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@@ -2309,9 +2309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2798,9 +2798,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
||||
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2812,9 +2812,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2826,9 +2826,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2840,9 +2840,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2854,9 +2854,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2868,9 +2868,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2882,9 +2882,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
|
||||
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2896,9 +2896,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
|
||||
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2910,9 +2910,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2924,9 +2924,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2938,9 +2938,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -2952,9 +2952,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -2966,9 +2966,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2980,9 +2980,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2994,9 +2994,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -3008,9 +3008,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -3022,9 +3022,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -3036,9 +3036,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3050,9 +3050,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3064,9 +3064,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3078,9 +3078,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3092,9 +3092,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3106,9 +3106,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3120,9 +3120,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3134,9 +3134,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3248,9 +3248,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
||||
"version": "19.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.11.tgz",
|
||||
"integrity": "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -3643,14 +3643,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
||||
"integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==",
|
||||
"version": "0.4.15",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz",
|
||||
"integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.27.7",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.5",
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.6",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -3658,27 +3658,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs3": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz",
|
||||
"integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==",
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz",
|
||||
"integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.5",
|
||||
"core-js-compat": "^3.43.0"
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.6",
|
||||
"core-js-compat": "^3.48.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-regenerator": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz",
|
||||
"integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==",
|
||||
"version": "0.6.6",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz",
|
||||
"integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.5"
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
@@ -3702,9 +3702,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz",
|
||||
"integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==",
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -3823,9 +3823,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001764",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
|
||||
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
|
||||
"version": "1.0.30001767",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz",
|
||||
"integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3967,13 +3967,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
|
||||
"integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==",
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz",
|
||||
"integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.0"
|
||||
"browserslist": "^4.28.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -4267,9 +4267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -5328,7 +5328,7 @@
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -6477,9 +6477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7091,9 +7091,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||
"version": "11.2.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
|
||||
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -7278,24 +7278,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3"
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
@@ -7333,9 +7333,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
|
||||
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
@@ -7628,9 +7628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7644,31 +7644,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.55.1",
|
||||
"@rollup/rollup-android-arm64": "4.55.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.55.1",
|
||||
"@rollup/rollup-darwin-x64": "4.55.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.55.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.55.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.55.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.55.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.55.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.55.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.55.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -9331,6 +9331,7 @@
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -9359,13 +9360,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
|
||||
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
"@isaacs/brace-expansion": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 37 KiB |
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"action_bar_logo_alt": "شعار ntfy",
|
||||
"action_bar_settings": "اﻹعدادات",
|
||||
"action_bar_clear_notifications": "محو كافة الإشعارات",
|
||||
"action_bar_clear_notifications": "امحُ كل الإشعارات",
|
||||
"action_bar_unsubscribe": "إلغاء الاشتراك",
|
||||
"message_bar_show_dialog": "إظهار مربع حوار النشر",
|
||||
"message_bar_publish": "نشر الرسالة",
|
||||
"nav_topics_title": "المواضيع التي تم الاشتراك فيها",
|
||||
"nav_button_all_notifications": "كافة الإشعارات",
|
||||
"nav_topics_title": "المواضيع المشترك فيها",
|
||||
"nav_button_all_notifications": "كل الإشعارات",
|
||||
"nav_button_settings": "اﻹعدادات",
|
||||
"nav_button_documentation": "الدليل",
|
||||
"nav_button_publish_message": "نشر الإشعار",
|
||||
"nav_button_subscribe": "اشترك في الموضوع",
|
||||
"nav_button_connecting": "جارٍ الاتصال",
|
||||
"alert_notification_permission_required_title": "تم تعطيل الإشعارات",
|
||||
"alert_notification_permission_required_title": "عُطّلت الإشعارات",
|
||||
"alert_notification_permission_required_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.",
|
||||
"notifications_list": "قائمة الإشعارات",
|
||||
"notifications_list_item": "إشعار",
|
||||
@@ -21,7 +21,7 @@
|
||||
"notifications_priority_x": "الأولوية {{priority}}",
|
||||
"notifications_new_indicator": "إشعار جديد",
|
||||
"notifications_attachment_image": "صورة مرفقة",
|
||||
"notifications_attachment_copy_url_button": "نسخ عنوان URL",
|
||||
"notifications_attachment_copy_url_button": "انسخ عنوان URL",
|
||||
"notifications_attachment_open_title": "انتقل إلى {{url}}",
|
||||
"notifications_attachment_link_expires": "تنتهي صلاحية الرابط {{date}}",
|
||||
"notifications_attachment_link_expired": "انتهت صلاحية رابط التنزيل",
|
||||
@@ -30,23 +30,23 @@
|
||||
"notifications_attachment_file_audio": "ملف صوتي",
|
||||
"notifications_attachment_file_app": "ملف تطبيق Android",
|
||||
"notifications_attachment_file_document": "وثيقة أخرى",
|
||||
"notifications_click_copy_url_button": "نسخ الرابط",
|
||||
"notifications_click_copy_url_button": "انسخ الرابط",
|
||||
"notifications_click_open_button": "فتح الرابط",
|
||||
"notifications_actions_open_url_title": "انتقل إلى {{url}}",
|
||||
"notifications_actions_not_supported": "هذا الإجراء غير مدعوم في تطبيق الويب",
|
||||
"action_bar_send_test_notification": "إرسال إشعار للاختبار",
|
||||
"action_bar_show_menu": "عرض القائمة",
|
||||
"action_bar_show_menu": "اعرض القائمة",
|
||||
"message_bar_type_message": "اكتب رسالة هنا",
|
||||
"alert_not_supported_title": "الإشعارات غير مدعومة",
|
||||
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
|
||||
"message_bar_error_publishing": "خطأ خلال نشر الإشعار",
|
||||
"notifications_delete": "حذف",
|
||||
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
|
||||
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
|
||||
"notifications_copied_to_clipboard": "نُسخ إلى الحافظة",
|
||||
"action_bar_toggle_mute": "اكتم / ألغِ كتم الإشعارات",
|
||||
"action_bar_toggle_action_menu": "فتح/إغلاق قائمة الإجراءات",
|
||||
"alert_notification_permission_required_button": "امنح الآن",
|
||||
"notifications_attachment_open_button": "فتح المرفق",
|
||||
"notifications_attachment_copy_url_title": "نسخ عنوان URL للمرفق إلى الحافظة",
|
||||
"notifications_attachment_copy_url_title": "انسخ عنوان URL للمرفق إلى الحافظة",
|
||||
"notifications_click_copy_url_title": "انسخ رابط URL إلى الحافظة",
|
||||
"notifications_none_for_topic_title": "لم تتلق بعد أية إشعارات حول هذا الموضوع.",
|
||||
"notifications_none_for_any_title": "لم تتلق أية إشعارات.",
|
||||
@@ -60,7 +60,7 @@
|
||||
"publish_dialog_priority_low": "أولوية منخفضة",
|
||||
"publish_dialog_priority_default": "الأولوية الافتراضية",
|
||||
"publish_dialog_priority_high": "أولوية عالية",
|
||||
"publish_dialog_base_url_label": "الرابط التشعبي للخدمة",
|
||||
"publish_dialog_base_url_label": "عنوان URL للخدمة",
|
||||
"publish_dialog_priority_max": "أولوية قصوى",
|
||||
"publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
|
||||
"publish_dialog_title_label": "العنوان",
|
||||
@@ -75,27 +75,27 @@
|
||||
"publish_dialog_attach_label": "الرابط التشعبي URL للمرفق",
|
||||
"publish_dialog_filename_placeholder": "اسم ملف المرفق",
|
||||
"publish_dialog_delay_label": "تأخير",
|
||||
"publish_dialog_delay_reset": "إزالة تأخر التسليم",
|
||||
"publish_dialog_delay_reset": "أزل تأخر التوصيل",
|
||||
"publish_dialog_chip_click_label": "انقر على عنوان URL",
|
||||
"publish_dialog_chip_email_label": "إعادة التوجيه إلى البريد الإلكتروني",
|
||||
"publish_dialog_chip_attach_file_label": "إرفاق ملف محلي",
|
||||
"publish_dialog_chip_topic_label": "تغيير الموضوع",
|
||||
"publish_dialog_button_cancel_sending": "إلغاء الإرسال",
|
||||
"publish_dialog_button_cancel_sending": "ألغِ الإرسال",
|
||||
"publish_dialog_button_send": "أرسل",
|
||||
"publish_dialog_checkbox_publish_another": "نشر آخر",
|
||||
"publish_dialog_attached_file_title": "الملف المرفق:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "اسم الملف المرفق",
|
||||
"publish_dialog_attached_file_remove": "إزالة الملف المرفق",
|
||||
"publish_dialog_attached_file_remove": "أزل الملف المرفق",
|
||||
"publish_dialog_drop_file_here": "قم بإسقاط ملف هنا",
|
||||
"emoji_picker_search_placeholder": "البحث عن رمز تعبيري",
|
||||
"emoji_picker_search_clear": "مسح البحث",
|
||||
"emoji_picker_search_clear": "امحُ البحث",
|
||||
"subscribe_dialog_subscribe_title": "الإشتراك في الموضوع",
|
||||
"subscribe_dialog_subscribe_use_another_label": "استخدام خادم آخر",
|
||||
"subscribe_dialog_subscribe_base_url_label": "الرابط التشعبي URL للخدمة",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "اشترِك",
|
||||
"subscribe_dialog_login_title": "تسجيل الدخول مطلوب",
|
||||
"subscribe_dialog_login_username_label": "اسم المستخدم، على سبيل المثال phil",
|
||||
"subscribe_dialog_login_password_label": "كلمة المرور",
|
||||
"subscribe_dialog_login_password_label": "كلمة السر",
|
||||
"subscribe_dialog_login_button_login": "الولوج",
|
||||
"subscribe_dialog_error_user_anonymous": "مجهول",
|
||||
"prefs_notifications_title": "الإشعارات",
|
||||
@@ -107,9 +107,9 @@
|
||||
"prefs_notifications_delete_after_three_hours": "بعد ثلاث ساعات",
|
||||
"prefs_notifications_delete_after_one_day": "بعد يوم واحد",
|
||||
"prefs_notifications_delete_after_one_month": "بعد شهر واحد",
|
||||
"prefs_notifications_delete_after_never_description": "لا يتم حذف الإشعارات تلقائيا مطلقا",
|
||||
"prefs_notifications_delete_after_one_week_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد",
|
||||
"prefs_notifications_delete_after_one_month_description": "يتم حذف الإشعارات تلقائيا بعد شهر واحد",
|
||||
"prefs_notifications_delete_after_never_description": "لا تُحذف الإشعارات تلقائيًا مطلقًا",
|
||||
"prefs_notifications_delete_after_one_week_description": "تُحذف الإشعارات تلقائيًا بعد أسبوع واحد",
|
||||
"prefs_notifications_delete_after_one_month_description": "تُحذف الإشعارات تلقائيًا بعد شهر واحد",
|
||||
"prefs_users_table": "قائمة المستخدمين",
|
||||
"prefs_users_edit_button": "تعديل المستخدم",
|
||||
"prefs_users_table_user_header": "المستخدم",
|
||||
@@ -127,76 +127,76 @@
|
||||
"priority_max": "قصوى",
|
||||
"error_boundary_title": "أوه لا ، لقد تحطم ntfy",
|
||||
"prefs_users_delete_button": "حذف المستخدم",
|
||||
"prefs_users_add_button": "إضافة مستخدم",
|
||||
"prefs_users_add_button": "أضف مستخدم",
|
||||
"prefs_notifications_min_priority_any": "مهما كانت الأولوية",
|
||||
"prefs_notifications_delete_after_one_week": "بعد أسبوع واحد",
|
||||
"prefs_notifications_delete_after_three_hours_description": "يتم حذف الإشعارات تلقائيا بعد ثلاث ساعات",
|
||||
"prefs_notifications_delete_after_one_day_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد",
|
||||
"prefs_notifications_delete_after_three_hours_description": "تُحذف الإشعارات تلقائيًا بعد ثلاث ساعات",
|
||||
"prefs_notifications_delete_after_one_day_description": "تُحذف الإشعارات تلقائيًا بعد يوم واحد",
|
||||
"prefs_users_title": "إدارة المستخدمين",
|
||||
"prefs_users_dialog_title_add": "إضافة مستخدم",
|
||||
"prefs_users_dialog_title_add": "أضف مستخدم",
|
||||
"prefs_users_dialog_title_edit": "تعديل المستخدم",
|
||||
"prefs_users_dialog_base_url_label": "عنوان URL للخدمة، على سبيل المثال، https://ntfy.sh",
|
||||
"publish_dialog_button_cancel": "إلغاء",
|
||||
"publish_dialog_button_cancel": "ألغِ",
|
||||
"publish_dialog_message_published": "تم نشر الإشعار",
|
||||
"prefs_users_dialog_password_label": "كلمة المرور",
|
||||
"prefs_users_dialog_password_label": "كلمة السر",
|
||||
"publish_dialog_base_url_placeholder": "عنوان URL للخدمة، على سبيل المثال، https://example.com",
|
||||
"publish_dialog_progress_uploading": "جارٍ التحميل…",
|
||||
"publish_dialog_topic_label": "اسم الموضوع",
|
||||
"publish_dialog_topic_reset": "إعادة تعيين الموضوع",
|
||||
"publish_dialog_email_reset": "إزالة إعادة توجيه البريد الإلكتروني",
|
||||
"publish_dialog_email_reset": "أزل إعادة توجيه البريد الإلكتروني",
|
||||
"publish_dialog_email_placeholder": "عنوان لإعادة توجيه الإشعار إليه، على سبيل المثال phil@example.com",
|
||||
"publish_dialog_other_features": "ميزات أخرى:",
|
||||
"publish_dialog_chip_attach_url_label": "إرفاق ملف عن طريق عنوان URL",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
|
||||
"prefs_notifications_sound_description_none": "لا تصدر الإشعارات أي صوت عند وصولها",
|
||||
"publish_dialog_chip_delay_label": "تأخير التسليم",
|
||||
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
|
||||
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
|
||||
"common_back": "الرجوع",
|
||||
"publish_dialog_chip_delay_label": "تأخير التوصيل",
|
||||
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة سر. الرجاء إدخال اسم المستخدم وكلمة السر للاشتراك.",
|
||||
"subscribe_dialog_subscribe_button_cancel": "ألغِ",
|
||||
"common_back": "ارجع",
|
||||
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
|
||||
"prefs_notifications_min_priority_title": "أولوية دنيا",
|
||||
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
|
||||
"notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.",
|
||||
"publish_dialog_click_label": "الرابط التشعبي URL للنقر",
|
||||
"publish_dialog_tags_placeholder": "قائمة علامات مفصولة بفواصل، على سبيل المثال تحذير, srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "قائمة العلامات مفصولة بفواصل، على سبيل المثال: تحذير، srv1-backup",
|
||||
"publish_dialog_attach_placeholder": "إرفاق ملف بعنوان URL ، على سبيل المثال https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "إزالة عنوان URL للمرفق",
|
||||
"publish_dialog_attach_reset": "أزل عنوان URL للمرفق",
|
||||
"subscribe_dialog_error_user_not_authorized": "المستخدم {{username}} غير مصرح به",
|
||||
"common_save": "حفظ",
|
||||
"common_add": "إضافة",
|
||||
"signup_form_username": "إسم المستخدم",
|
||||
"signup_form_confirm_password": "تأكيد كلمة المرور",
|
||||
"common_save": "احفظ",
|
||||
"common_add": "أضف",
|
||||
"signup_form_username": "اسم المستخدم",
|
||||
"signup_form_confirm_password": "أكِّد كلمة السر",
|
||||
"login_title": "تسجيل الدخول إلى حسابك ntfy",
|
||||
"login_form_button_submit": "الولوج",
|
||||
"login_link_signup": "إنشاء حساب",
|
||||
"login_disabled": "تم تعطيل تسجيل الدخول",
|
||||
"action_bar_account": "الحساب",
|
||||
"action_bar_change_display_name": "تغيير الإسم المعروض",
|
||||
"action_bar_change_display_name": "غيّر الإسم المعروض",
|
||||
"signup_error_creation_limit_reached": "تم بلوغ حد إنشاء الحسابات",
|
||||
"action_bar_reservation_add": "حجز الموضوع",
|
||||
"action_bar_reservation_edit": "تغيير الحجز",
|
||||
"action_bar_profile_title": "الملف التعريفي",
|
||||
"action_bar_profile_settings": "اﻹعدادات",
|
||||
"action_bar_profile_logout": "الخروج",
|
||||
"action_bar_profile_logout": "اخرج",
|
||||
"action_bar_sign_in": "الولوج",
|
||||
"action_bar_sign_up": "إنشاء حساب",
|
||||
"action_bar_sign_up": "أنشئ حساب",
|
||||
"nav_button_account": "الحساب",
|
||||
"nav_upgrade_banner_label": "قم بالترقية إلى NTFY Pro",
|
||||
"reserve_dialog_checkbox_label": "حجز الموضوع وإعداد الوصول",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "توليد إسم",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "ولِّد اسم",
|
||||
"subscribe_dialog_error_topic_already_reserved": "الموضوع محجوز بالفعل",
|
||||
"account_basics_title": "الحساب",
|
||||
"account_basics_username_title": "إسم المستخدم",
|
||||
"account_basics_username_description": "مرحبًا، هذا أنت ❤",
|
||||
"account_basics_username_admin_tooltip": "أنت مدير",
|
||||
"account_basics_password_title": "كلمة المرور",
|
||||
"account_basics_password_description": "غيّر كلمة مرور حسابك",
|
||||
"account_basics_password_dialog_title": "تغيير كلمة المرور",
|
||||
"account_basics_password_dialog_current_password_label": "كلمة المرور الحالية",
|
||||
"account_basics_password_dialog_new_password_label": "كلمة المرور الجديدة",
|
||||
"account_basics_password_dialog_confirm_password_label": "تأكيد كلمة المرور",
|
||||
"account_basics_password_dialog_button_submit": "تغيير كلمة المرور",
|
||||
"account_basics_password_dialog_current_password_incorrect": "الكلمة السرية خاطئة",
|
||||
"account_basics_password_title": "كلمة السر",
|
||||
"account_basics_password_description": "غيّر كلمة سر حسابك",
|
||||
"account_basics_password_dialog_title": "غيّر كلمة السر",
|
||||
"account_basics_password_dialog_current_password_label": "كلمة السر الحالية",
|
||||
"account_basics_password_dialog_new_password_label": "كلمة السر جديدة",
|
||||
"account_basics_password_dialog_confirm_password_label": "أكِّد كلمة السر",
|
||||
"account_basics_password_dialog_button_submit": "غيّر كلمة السر",
|
||||
"account_basics_password_dialog_current_password_incorrect": "كلمة السر غير صحيحة",
|
||||
"account_usage_title": "الإستخدام",
|
||||
"account_usage_of_limit": "من {{limit}}",
|
||||
"account_usage_unlimited": "غير محدود",
|
||||
@@ -212,13 +212,13 @@
|
||||
"account_usage_attachment_storage_title": "تخزين المرفقات",
|
||||
"account_delete_title": "حذف الحساب",
|
||||
"account_delete_description": "احذف حسابك نهائيا",
|
||||
"account_delete_dialog_label": "كلمة المرور",
|
||||
"account_delete_dialog_label": "كلمة السر",
|
||||
"account_upgrade_dialog_title": "تغيير فئة الحساب",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} رسائل يومية",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} من رسائل البريد الإلكتروني اليومية",
|
||||
"account_upgrade_dialog_button_cancel": "إلغاء",
|
||||
"account_upgrade_dialog_button_cancel": "ألغِ",
|
||||
"account_upgrade_dialog_button_pay_now": "ادفع الآن واشترك",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "إلغاء الاشتراك",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "ألغِ الاشتراك",
|
||||
"account_tokens_title": "رموز الوصول",
|
||||
"account_tokens_table_token_header": "الرمز المميز",
|
||||
"account_tokens_table_last_access_header": "آخر وصول",
|
||||
@@ -235,7 +235,7 @@
|
||||
"account_tokens_dialog_label": "التسمية، على سبيل المثال إشعارات الرادار",
|
||||
"account_tokens_dialog_button_create": "إنشاء رمز مميز",
|
||||
"account_tokens_dialog_button_update": "تحديث الرمز المميز",
|
||||
"account_tokens_dialog_button_cancel": "إلغاء",
|
||||
"account_tokens_dialog_button_cancel": "ألغِ",
|
||||
"account_tokens_dialog_expires_label": "تنتهي صلاحية الرمز المميز للوصول في",
|
||||
"account_tokens_dialog_expires_unchanged": "اترك تاريخ انتهاء الصلاحية دون تغيير",
|
||||
"account_tokens_dialog_expires_x_hours": "تنتهي صلاحية الرمز المميز في {{hours}} ساعات",
|
||||
@@ -243,7 +243,7 @@
|
||||
"account_tokens_delete_dialog_title": "حذف الرمز المميز للوصول",
|
||||
"account_tokens_delete_dialog_submit_button": "حذف الرمز المميز نهائيا",
|
||||
"prefs_users_table_cannot_delete_or_edit": "لا يمكن حذف أو تحرير المستخدم الذي قام بتسجيل الدخول",
|
||||
"prefs_reservations_add_button": "إضافة موضوع محجوز",
|
||||
"prefs_reservations_add_button": "أضف موضوع محجوز",
|
||||
"prefs_reservations_table": "جدول المواضيع المحجوزة",
|
||||
"prefs_reservations_table_topic_header": "الموضوع",
|
||||
"prefs_reservations_table_access_header": "الوصول",
|
||||
@@ -256,19 +256,19 @@
|
||||
"prefs_reservations_dialog_access_label": "الوصول",
|
||||
"reservation_delete_dialog_action_delete_title": "حذف الرسائل والمرفقات المخزنة مؤقتا",
|
||||
"reservation_delete_dialog_submit_button": "حذف الحجز",
|
||||
"signup_title": "إنشاء حساب ntfy",
|
||||
"common_cancel": "إلغاء",
|
||||
"signup_form_password": "كلمة المرور",
|
||||
"signup_title": "أنشئ حساب ntfy",
|
||||
"common_cancel": "ألغِ",
|
||||
"signup_form_password": "كلمة السر",
|
||||
"signup_already_have_account": "هل لديك حساب؟ قم بتسجيل الدخول!",
|
||||
"signup_form_button_submit": "إنشاء حساب",
|
||||
"signup_disabled": "تم تعطيل التسجيل",
|
||||
"signup_form_button_submit": "أنشئ حساب",
|
||||
"signup_disabled": "عُطّل التسجيل",
|
||||
"display_name_dialog_placeholder": "الإسم المعروض",
|
||||
"display_name_dialog_title": "تغيير الإسم المعروض",
|
||||
"account_basics_tier_basic": "أساسي",
|
||||
"account_usage_emails_title": "رسائل البريد الإلكتروني المرسلة",
|
||||
"account_usage_reservations_none": "لا توجد مواضيع محجوزة لهذا الحساب",
|
||||
"account_usage_cannot_create_portal_session": "تعذر فتح بوابة الفوترة",
|
||||
"account_delete_dialog_button_cancel": "إلغاء",
|
||||
"account_delete_dialog_button_cancel": "ألغِ",
|
||||
"account_delete_dialog_button_submit": "حذف الحساب نهائيا",
|
||||
"account_upgrade_dialog_button_update_subscription": "تحديث الاشتراك",
|
||||
"account_tokens_table_copied_to_clipboard": "تم نسخ الرمز المميز للوصول",
|
||||
@@ -276,31 +276,31 @@
|
||||
"prefs_reservations_table_everyone_read_only": "يمكنني النشر والاشتراك ، ويمكن للجميع الاشتراك",
|
||||
"prefs_reservations_table_click_to_subscribe": "انقر للاشتراك",
|
||||
"reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا",
|
||||
"action_bar_reservation_delete": "إزالة الحجز",
|
||||
"action_bar_reservation_delete": "أزل الحجز",
|
||||
"display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.",
|
||||
"prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة المرور في التخزين المحلي للمتصفح.",
|
||||
"prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة السر في التخزين المحلي للمتصفح.",
|
||||
"notifications_more_details": "لمزيد من المعلومات، الرجاء الاطّلاع على <websiteLink>موقع الويب</websiteLink> أو على <docsLink>الدليل</docsLink>.",
|
||||
"publish_dialog_details_examples_description": "للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى <docsLink>الدليل</docsLink>.",
|
||||
"subscribe_dialog_subscribe_description": "قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \"PUT/POST\".",
|
||||
"prefs_notifications_sound_description_some": "تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها",
|
||||
"notifications_none_for_topic_description": "لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع.",
|
||||
"priority_low": "منخفضة",
|
||||
"signup_form_toggle_password_visibility": "تبديل رؤية كلمة المرور",
|
||||
"signup_form_toggle_password_visibility": "تبديل رؤية كلمة السر",
|
||||
"account_usage_limits_reset_daily": "يعاد تحديد حدود الاستخدام يوميا في منتصف الليل (UTC)",
|
||||
"account_tokens_table_label_header": "المُلصَقة",
|
||||
"account_upgrade_dialog_button_redirect_signup": "تسجيل فوري",
|
||||
"account_upgrade_dialog_tier_current_label": "الحالي",
|
||||
"account_tokens_dialog_expires_x_days": "تنتهي صلاحية الرمز المميز في غضون {{days}} أيام",
|
||||
"prefs_reservations_dialog_title_add": "حجز موضوع",
|
||||
"prefs_reservations_dialog_title_add": "احجز موضوع",
|
||||
"prefs_reservations_description": "يمكنك حجز أسماء الموضوعات للاستخدام الشخصي هنا. يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات الوصول للمستخدمين الآخرين إلى الموضوع.",
|
||||
"prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات المرور مع حسابك.",
|
||||
"prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات السر مع حسابك.",
|
||||
"reservation_delete_dialog_action_delete_description": "سيتم حذف الرسائل والمرفقات المخزنة مؤقتا نهائيا. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"notifications_actions_http_request_title": "إرسال طلب HTTP {{method}} إلى {{url}}",
|
||||
"notifications_none_for_any_description": "لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك.",
|
||||
"error_boundary_description": "من الواضح أن هذا لا ينبغي أن يحدث. آسف جدًا بشأن هذا. <br/> إن كان لديك دقيقة، يرجى <githubLink> الإبلاغ عن ذلك على GitHub </githubLink> ، أو إعلامنا عبر <discordLink> Discord </discordLink> أو <matrixLink> Matrix </matrixLink>.",
|
||||
"nav_button_muted": "الإشعارات المكتومة",
|
||||
"priority_min": "دنيا",
|
||||
"signup_error_username_taken": "تم حجز اسم المستخدم {{username}} مِن قَبلُ",
|
||||
"signup_error_username_taken": "تم حجز اسم المستخدم {{username}} بالفعل",
|
||||
"action_bar_reservation_limit_reached": "بلغت الحد الأقصى",
|
||||
"prefs_reservations_delete_button": "إعادة تعيين الوصول إلى الموضوع",
|
||||
"prefs_reservations_edit_button": "تعديل الوصول إلى موضوع",
|
||||
@@ -323,7 +323,7 @@
|
||||
"account_upgrade_dialog_interval_yearly": "سنويا",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "لا توجد مواضيع محجوزة",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "وفر {{discount}}٪",
|
||||
"publish_dialog_click_reset": "إزالة الرابط التشعبي URL للنقر",
|
||||
"publish_dialog_click_reset": "أزل الرابط URL للنقر",
|
||||
"prefs_notifications_min_priority_description_max": "إظهار الإشعارات إذا كانت الأولوية 5 (كحد أقصى)",
|
||||
"publish_dialog_attachment_limits_file_reached": "يتجاوز الحد الأقصى للملف {{fileSizeLimit}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "يتجاوز الحصة، {{remainingBytes}} متبقية",
|
||||
@@ -335,16 +335,16 @@
|
||||
"prefs_appearance_theme_light": "الوضع النهاري",
|
||||
"publish_dialog_checkbox_markdown": "تنسيق على هيئة ماركداون",
|
||||
"alert_not_supported_context_description": "الإشعارات مسموحة فقط على بروتوكول HTTPS المأمن, هذه القيود <mdnLink>خصائص الإشعارات</mdnLink>",
|
||||
"publish_dialog_call_reset": "حذف اتصال بالهاتف",
|
||||
"publish_dialog_call_reset": "احذف اتصال بالهاتف",
|
||||
"publish_dialog_call_label": "اتصال هاتفي",
|
||||
"publish_dialog_chip_call_label": "اتصال هاتفي",
|
||||
"publish_dialog_delay_placeholder": "تأخير التوصيل, مثال {{unixTimestamp}}, {{relativeTime}}, او \"{{naturalLanguage}}\" (اللغة الإنجليزية فقط)",
|
||||
"publish_dialog_delay_placeholder": "تأخير التوصيل، مثال {{unixTimestamp}}، {{relativeTime}}، أو \"{{naturalLanguage}}\" (اللغة الإنجليزية فقط)",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "تجاوز حجم {{fileSizeLimit}} الملف, {{remainingBytes}} متبقي",
|
||||
"prefs_reservations_dialog_title_delete": "حذف حجز موضوع",
|
||||
"publish_dialog_call_item": "اتصل برقم الهاتف {{number}}",
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "لا يوجد ارقام هواتف معرفة",
|
||||
"action_bar_mute_notifications": "كتم الإشعارات",
|
||||
"action_bar_unmute_notifications": "إلغاء كتم الإشعارات",
|
||||
"action_bar_unmute_notifications": "ألغِ كتم الإشعارات",
|
||||
"alert_notification_ios_install_required_description": "اضغط على زر المشاركة ثم إضافة إلى الصفحة الرئيسية لتستقبل الإشعارات على أجهزة أبل",
|
||||
"alert_notification_ios_install_required_title": "يجب تثبيت الصفحة",
|
||||
"alert_notification_permission_denied_description": "الرجاء اعادة منح الصلاحيات في المتصفح",
|
||||
@@ -359,6 +359,10 @@
|
||||
"account_basics_phone_numbers_dialog_verify_button_call": "اتصل بي",
|
||||
"account_basics_phone_numbers_dialog_code_label": "رمز التحقّق",
|
||||
"account_upgrade_dialog_tier_price_per_month": "شهر",
|
||||
"prefs_appearance_theme_title": "الحُلّة",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً"
|
||||
"prefs_appearance_theme_title": "السمة",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً",
|
||||
"prefs_appearance_theme_system": "النظام (الافتراضي)",
|
||||
"prefs_notifications_min_priority_low_and_higher": "أولوية منخفضة وأعلى",
|
||||
"prefs_notifications_min_priority_default_and_higher": "الأولوية الافتراضية وما فوقها",
|
||||
"prefs_notifications_min_priority_high_and_higher": "أولوية عالية وأعلى"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"publish_dialog_attachment_limits_quota_reached": "надвишава квотата, остават {{remainingBytes}}",
|
||||
"publish_dialog_priority_high": "Висок приоритет",
|
||||
"publish_dialog_priority_default": "Подразбиран приоритет",
|
||||
"publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за диска",
|
||||
"publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за дисково пространство",
|
||||
"publish_dialog_tags_label": "Етикети",
|
||||
"publish_dialog_email_label": "Адрес на електронна поща",
|
||||
"publish_dialog_priority_max": "Най-висок приоритет",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"publish_dialog_tags_placeholder": "Komma-getrennte Liste von Tags, z.B. Warnung, srv1-Backup",
|
||||
"publish_dialog_priority_label": "Priorität",
|
||||
"publish_dialog_filename_label": "Dateiname",
|
||||
"publish_dialog_title_placeholder": "Benachrichtigungs-Titel, z.B. CPU-Last-Warnung",
|
||||
"publish_dialog_title_placeholder": "Benachrichtigungstitel, z. B. Speicherplatzwarnung",
|
||||
"publish_dialog_tags_label": "Tags",
|
||||
"publish_dialog_click_label": "Klick-URL",
|
||||
"publish_dialog_click_placeholder": "URL die geöffnet werden soll, wenn die Benachrichtigung angeklickt wird",
|
||||
|
||||
@@ -357,6 +357,8 @@
|
||||
"prefs_users_dialog_title_add": "Add user",
|
||||
"prefs_users_dialog_title_edit": "Edit user",
|
||||
"prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh",
|
||||
"prefs_users_dialog_base_url_invalid": "Invalid URL format. Must start with http:// or https://",
|
||||
"prefs_users_dialog_base_url_exists": "A user for this service URL already exists",
|
||||
"prefs_users_dialog_username_label": "Username, e.g. phil",
|
||||
"prefs_users_dialog_password_label": "Password",
|
||||
"prefs_appearance_title": "Appearance",
|
||||
|
||||
52
web/public/static/langs/he.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"common_cancel": "ביטול",
|
||||
"common_save": "שמירה",
|
||||
"common_add": "הוספה",
|
||||
"common_back": "חזרה",
|
||||
"common_copy_to_clipboard": "העתקה ללוח הגזירים",
|
||||
"signup_title": "יצירת חשבון ntfy",
|
||||
"signup_form_username": "שם משתמש",
|
||||
"signup_form_password": "סיסמה",
|
||||
"signup_form_confirm_password": "אישור סיסמה",
|
||||
"signup_form_button_submit": "הרשמה",
|
||||
"signup_form_toggle_password_visibility": "הצגת/הסתרת סיסמה",
|
||||
"signup_already_have_account": "כבר יש לך חשבון? אפשר להיכנס איתו!",
|
||||
"signup_disabled": "הרשמה כבויה",
|
||||
"signup_error_username_taken": "שם המשתמש {{username}} כבר תפוס",
|
||||
"signup_error_creation_limit_reached": "הגעת למגבלת יצירת חשבונות",
|
||||
"login_title": "כניסה לחשבון ה־ntfy שלך",
|
||||
"login_form_button_submit": "כניסה",
|
||||
"login_link_signup": "הרשמה",
|
||||
"login_disabled": "הכניסה מושבתת",
|
||||
"action_bar_show_menu": "הצגת תפריט",
|
||||
"action_bar_logo_alt": "הלוגו של ntfy",
|
||||
"action_bar_settings": "הגדרות",
|
||||
"action_bar_account": "חשבון",
|
||||
"action_bar_change_display_name": "החלפת שם תצוגה",
|
||||
"action_bar_reservation_add": "שימור נושא",
|
||||
"action_bar_reservation_edit": "החלפת מצב שימור",
|
||||
"action_bar_reservation_delete": "הסרת שימור",
|
||||
"action_bar_reservation_limit_reached": "הגעת למגבלה",
|
||||
"action_bar_send_test_notification": "שליחת התראת ניסוי",
|
||||
"action_bar_clear_notifications": "לפנות את כל ההתראות",
|
||||
"action_bar_mute_notifications": "השתקת התראות",
|
||||
"action_bar_unmute_notifications": "ביטול השתקת התראות",
|
||||
"action_bar_unsubscribe": "ביטול מינוי",
|
||||
"notifications_list_item": "התראה",
|
||||
"notifications_mark_read": "סימון כנקראה",
|
||||
"notifications_delete": "מחיקה",
|
||||
"notifications_copied_to_clipboard": "הועתקה ללוח הגזירים",
|
||||
"notifications_tags": "תגיות",
|
||||
"notifications_priority_x": "עדיפות {{priority}}",
|
||||
"notifications_new_indicator": "התראה חדשה",
|
||||
"notifications_attachment_copy_url_button": "העתקת כתובת",
|
||||
"notifications_attachment_open_title": "מעבר אל {{url}}",
|
||||
"notifications_attachment_open_button": "פתיחת צרופה",
|
||||
"notifications_attachment_link_expires": "תוקף הקישור פג ב־{{date}}",
|
||||
"notifications_attachment_link_expired": "תוקף קישור ההורדה פג",
|
||||
"notifications_actions_failed_notification": "פעולה לא מוצלחת",
|
||||
"notifications_none_for_topic_title": "לא קיבלת התראות בנושא הזה עדיין.",
|
||||
"notifications_none_for_topic_description": "כדי לשלוח התראות לנושא הזה, צריך לשלוח PUT או POST לכתובת הנושא הזה.",
|
||||
"notifications_none_for_any_title": "לא קיבלת התראות כלל.",
|
||||
"notifications_no_subscriptions_title": "נראה שלא נרשמת למינויים עדיין."
|
||||
}
|
||||
@@ -30,11 +30,11 @@
|
||||
"publish_dialog_topic_label": "Название темы",
|
||||
"publish_dialog_topic_placeholder": "Название темы, например phil_alerts",
|
||||
"publish_dialog_title_label": "Заголовок",
|
||||
"publish_dialog_title_placeholder": "Заголовок уведомления, например Disk space alert",
|
||||
"publish_dialog_title_placeholder": "Заголовок уведомления, например, Предупреждение о занятости диска",
|
||||
"publish_dialog_message_label": "Сообщение",
|
||||
"publish_dialog_message_placeholder": "Введите сообщение здесь",
|
||||
"publish_dialog_tags_label": "Тэги",
|
||||
"publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например: warning, srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "Ярлыки, разделенные запятыми, например: warning, srv1-backup",
|
||||
"publish_dialog_priority_label": "Приоритет",
|
||||
"publish_dialog_click_label": "Ссылка при открытии",
|
||||
"publish_dialog_click_placeholder": "URL-адрес, который откроется при нажатии на уведомление",
|
||||
@@ -242,8 +242,8 @@
|
||||
"action_bar_reservation_delete": "Удалить резервирование",
|
||||
"action_bar_profile_title": "Профиль",
|
||||
"action_bar_profile_settings": "Настройки",
|
||||
"action_bar_profile_logout": "Выход",
|
||||
"action_bar_sign_in": "Вход",
|
||||
"action_bar_profile_logout": "Выйти",
|
||||
"action_bar_sign_in": "Войти",
|
||||
"action_bar_sign_up": "Регистрация",
|
||||
"action_bar_change_display_name": "Изменить псевдоним",
|
||||
"message_bar_publish": "Опубликовать сообщение",
|
||||
@@ -395,7 +395,7 @@
|
||||
"prefs_notifications_web_push_title": "Фоновые уведомления",
|
||||
"prefs_notifications_web_push_enabled_description": "Уведомления приходят даже когда веб-приложение не запущено (через Web Push)",
|
||||
"prefs_notifications_web_push_disabled_description": "Уведомления приходят, когда веб-приложение запущено (через WebSocket)",
|
||||
"prefs_appearance_theme_title": "Тема",
|
||||
"prefs_appearance_theme_title": "Тема оформления",
|
||||
"prefs_notifications_web_push_enabled": "Включено для {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Выключено",
|
||||
"notifications_actions_failed_notification": "Неудачное действие",
|
||||
@@ -403,5 +403,7 @@
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Уведомления с других серверов не будут получены, когда веб-приложение не открыто",
|
||||
"prefs_appearance_theme_system": "Как в системе (по умолчанию)",
|
||||
"prefs_appearance_theme_dark": "Тёмная",
|
||||
"prefs_appearance_theme_light": "Светлая"
|
||||
"prefs_appearance_theme_light": "Светлая",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Пользователя, созданного автоматически, нельзя изменить или удалить",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить"
|
||||
}
|
||||
|
||||
@@ -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ť"
|
||||
}
|
||||
|
||||
@@ -403,5 +403,7 @@
|
||||
"web_push_subscription_expiring_body": "Öppna ntfy för att fortsätta ta emot notifikationer",
|
||||
"web_push_unknown_notification_body": "Du kan behöva uppdatera ntfy genom att öppna webbappen",
|
||||
"prefs_notifications_web_push_disabled_description": "Meddelanden tas emot när webbappen körs (via WebSocket)",
|
||||
"web_push_unknown_notification_title": "Okänd notifikation mottagen från server"
|
||||
"web_push_unknown_notification_title": "Okänd notifikation mottagen från server",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "En provisionerad användare kan inte redigeras eller tas bort",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Kan inte redigera eller ta bort en provisionerad token"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||
import { NetworkFirst } from "workbox-strategies";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
import { dbAsync } from "../src/app/db";
|
||||
import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils";
|
||||
import { ACTION_HTTP, ACTION_VIEW } from "../src/app/actions";
|
||||
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
|
||||
import initI18n from "../src/app/i18n";
|
||||
import {
|
||||
EVENT_MESSAGE,
|
||||
@@ -38,6 +39,13 @@ const handlePushMessage = async (data) => {
|
||||
|
||||
console.log("[ServiceWorker] Message received", data);
|
||||
|
||||
// Look up subscription for baseUrl and topic
|
||||
const subscription = await db.subscriptions.get(subscriptionId);
|
||||
if (!subscription) {
|
||||
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete existing notification with same sequence ID (if any)
|
||||
const sequenceId = message.sequence_id || message.id;
|
||||
if (sequenceId) {
|
||||
@@ -65,10 +73,11 @@ const handlePushMessage = async (data) => {
|
||||
|
||||
await self.registration.showNotification(
|
||||
...toNotificationParams({
|
||||
subscriptionId,
|
||||
message,
|
||||
defaultTitle: message.topic,
|
||||
topicRoute: new URL(message.topic, self.location.origin).toString(),
|
||||
baseUrl: subscription.baseUrl,
|
||||
topic: subscription.topic,
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -81,18 +90,23 @@ const handlePushMessageDelete = async (data) => {
|
||||
const db = await dbAsync();
|
||||
console.log("[ServiceWorker] Deleting notification sequence", data);
|
||||
|
||||
// Look up subscription for baseUrl and topic
|
||||
const subscription = await db.subscriptions.get(subscriptionId);
|
||||
if (!subscription) {
|
||||
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete notification with the same sequence_id
|
||||
const sequenceId = message.sequence_id;
|
||||
if (sequenceId) {
|
||||
await db.notifications.where({ subscriptionId, sequenceId }).delete();
|
||||
}
|
||||
|
||||
// Close browser notification with matching tag
|
||||
const tag = message.sequence_id || message.id;
|
||||
if (tag) {
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach((notification) => notification.close());
|
||||
}
|
||||
// Close browser notification with matching tag (scoped by topic)
|
||||
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach((notification) => notification.close());
|
||||
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
@@ -108,18 +122,23 @@ const handlePushMessageClear = async (data) => {
|
||||
const db = await dbAsync();
|
||||
console.log("[ServiceWorker] Marking notification as read", data);
|
||||
|
||||
// Look up subscription for baseUrl and topic
|
||||
const subscription = await db.subscriptions.get(subscriptionId);
|
||||
if (!subscription) {
|
||||
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark notification as read (set new = 0)
|
||||
const sequenceId = message.sequence_id;
|
||||
if (sequenceId) {
|
||||
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
// Close browser notification with matching tag
|
||||
const tag = message.sequence_id || message.id;
|
||||
if (tag) {
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach((notification) => notification.close());
|
||||
}
|
||||
// Close browser notification with matching tag (scoped by topic)
|
||||
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach((notification) => notification.close());
|
||||
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
@@ -219,9 +238,25 @@ const handleClick = async (event) => {
|
||||
if (event.action) {
|
||||
const action = event.notification.data.message.actions.find(({ label }) => event.action === label);
|
||||
|
||||
if (action.action === "view") {
|
||||
// Helper to clear notification and mark as read
|
||||
const clearNotification = async () => {
|
||||
event.notification.close();
|
||||
const { subscriptionId, message: msg } = event.notification.data;
|
||||
const seqId = msg.sequence_id || msg.id;
|
||||
if (subscriptionId && seqId) {
|
||||
const db = await dbAsync();
|
||||
await db.notifications.where({ subscriptionId, sequenceId: seqId }).modify({ new: 0 });
|
||||
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||
self.navigator.setAppBadge?.(badgeCount);
|
||||
}
|
||||
};
|
||||
|
||||
if (action.action === ACTION_VIEW) {
|
||||
self.clients.openWindow(action.url);
|
||||
} else if (action.action === "http") {
|
||||
if (action.clear) {
|
||||
await clearNotification();
|
||||
}
|
||||
} else if (action.action === ACTION_HTTP) {
|
||||
try {
|
||||
const response = await fetch(action.url, {
|
||||
method: action.method ?? "POST",
|
||||
@@ -232,6 +267,11 @@ const handleClick = async (event) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Only clear on success
|
||||
if (action.clear) {
|
||||
await clearNotification();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ServiceWorker] Error performing http action", e);
|
||||
self.registration.showNotification(`${t("notifications_actions_failed_notification")}: ${action.label} (${action.action})`, {
|
||||
@@ -241,10 +281,6 @@ const handleClick = async (event) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (action.clear) {
|
||||
event.notification.close();
|
||||
}
|
||||
} else if (message.click) {
|
||||
self.clients.openWindow(message.click);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
|
||||
import { toNotificationParams } from "./notificationUtils";
|
||||
import { notificationTag, toNotificationParams } from "./notificationUtils";
|
||||
import prefs from "./Prefs";
|
||||
import routes from "../components/routes";
|
||||
|
||||
@@ -23,21 +23,23 @@ class Notifier {
|
||||
const registration = await this.serviceWorkerRegistration();
|
||||
await registration.showNotification(
|
||||
...toNotificationParams({
|
||||
subscriptionId: subscription.id,
|
||||
message: notification,
|
||||
defaultTitle,
|
||||
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
|
||||
baseUrl: subscription.baseUrl,
|
||||
topic: subscription.topic,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async cancel(notification) {
|
||||
async cancel(subscription, notification) {
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tag = notification.sequence_id || notification.id;
|
||||
console.log(`[Notifier] Cancelling notification with ${tag}`);
|
||||
const sequenceId = notification.sequence_id || notification.id;
|
||||
const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId);
|
||||
console.log(`[Notifier] Cancelling notification with tag ${tag}`);
|
||||
const registration = await this.serviceWorkerRegistration();
|
||||
const notifications = await registration.getNotifications({ tag });
|
||||
notifications.forEach((n) => n.close());
|
||||
|
||||
7
web/src/app/actions.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Action types for ntfy messages
|
||||
// These correspond to the server action types in server/actions.go
|
||||
|
||||
export const ACTION_VIEW = "view";
|
||||
export const ACTION_BROADCAST = "broadcast";
|
||||
export const ACTION_HTTP = "http";
|
||||
export const ACTION_COPY = "copy";
|
||||
@@ -2,6 +2,7 @@
|
||||
// and cannot be used in the service worker
|
||||
|
||||
import emojisMapped from "./emojisMapped";
|
||||
import { ACTION_HTTP, ACTION_VIEW } from "./actions";
|
||||
|
||||
const toEmojis = (tags) => {
|
||||
if (!tags) return [];
|
||||
@@ -50,8 +51,17 @@ export const isImage = (attachment) => {
|
||||
export const icon = "/static/images/ntfy.png";
|
||||
export const badge = "/static/images/mask-icon.svg";
|
||||
|
||||
export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
|
||||
/**
|
||||
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
|
||||
* This ensures notifications from different topics with the same sequence ID don't collide.
|
||||
*/
|
||||
export const notificationTag = (baseUrl, topic, sequenceId) => `${baseUrl}/${topic}/${sequenceId}`;
|
||||
|
||||
export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUrl, topic }) => {
|
||||
const image = isImage(message.attachment) ? message.attachment.url : undefined;
|
||||
const sequenceId = message.sequence_id || message.id;
|
||||
const tag = notificationTag(baseUrl, topic, sequenceId);
|
||||
const subscriptionId = `${baseUrl}/${topic}`;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
|
||||
return [
|
||||
@@ -62,16 +72,17 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
|
||||
icon,
|
||||
image,
|
||||
timestamp: message.time * 1000,
|
||||
tag: message.sequence_id || message.id, // Update notification if there is a sequence ID
|
||||
tag, // Scoped by baseUrl/topic/sequenceId to avoid cross-topic collisions
|
||||
renotify: true,
|
||||
silent: false,
|
||||
// This is used by the notification onclick event
|
||||
data: {
|
||||
subscriptionId,
|
||||
message,
|
||||
topicRoute,
|
||||
},
|
||||
actions: message.actions
|
||||
?.filter(({ action }) => action === "view" || action === "http")
|
||||
?.filter(({ action }) => action === ACTION_VIEW || action === ACTION_HTTP)
|
||||
.map(({ label }) => ({
|
||||
action: label,
|
||||
title: label,
|
||||
|
||||
@@ -8,6 +8,7 @@ import pop from "../sounds/pop.mp3";
|
||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||
import config from "./config";
|
||||
import emojisMapped from "./emojisMapped";
|
||||
import { THEME } from "./Prefs";
|
||||
|
||||
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||
@@ -274,6 +275,84 @@ export const urlB64ToUint8Array = (base64String) => {
|
||||
return outputArray;
|
||||
};
|
||||
|
||||
export const darkModeEnabled = (prefersDarkMode, themePreference) => {
|
||||
switch (themePreference) {
|
||||
case THEME.DARK:
|
||||
return true;
|
||||
|
||||
case THEME.LIGHT:
|
||||
return false;
|
||||
|
||||
case THEME.SYSTEM:
|
||||
default:
|
||||
return prefersDarkMode;
|
||||
}
|
||||
};
|
||||
|
||||
// Canvas-based favicon with a red notification dot when there are unread messages
|
||||
let faviconCanvas;
|
||||
let faviconOriginalIcon;
|
||||
|
||||
const loadFaviconIcon = () =>
|
||||
new Promise((resolve) => {
|
||||
if (faviconOriginalIcon) {
|
||||
resolve(faviconOriginalIcon);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
faviconOriginalIcon = img;
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
// Use PNG instead of ICO — .ico files can't be reliably drawn to canvas in all browsers
|
||||
img.src = "/static/images/ntfy.png";
|
||||
});
|
||||
|
||||
export const updateFavicon = async (count) => {
|
||||
const size = 32;
|
||||
const img = await loadFaviconIcon();
|
||||
if (!img) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!faviconCanvas) {
|
||||
faviconCanvas = document.createElement("canvas");
|
||||
faviconCanvas.width = size;
|
||||
faviconCanvas.height = size;
|
||||
}
|
||||
|
||||
const ctx = faviconCanvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
|
||||
if (count > 0) {
|
||||
const dotRadius = 5;
|
||||
const borderWidth = 2;
|
||||
const dotX = size - dotRadius - borderWidth + 1;
|
||||
const dotY = size - dotRadius - borderWidth + 1;
|
||||
|
||||
// Transparent border: erase a ring around the dot so the icon doesn't bleed into it
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.beginPath();
|
||||
ctx.arc(dotX, dotY, dotRadius + borderWidth, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Red dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(dotX, dotY, dotRadius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "#dc3545";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const link = document.querySelector("link[rel='icon']");
|
||||
if (link) {
|
||||
link.href = faviconCanvas.toDataURL("image/png");
|
||||
}
|
||||
};
|
||||
|
||||
export const copyToClipboard = (text) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
|
||||
@@ -11,7 +11,7 @@ import ActionBar from "./ActionBar";
|
||||
import Preferences from "./Preferences";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import userManager from "../app/UserManager";
|
||||
import { expandUrl, getKebabCaseLangStr } from "../app/utils";
|
||||
import { expandUrl, getKebabCaseLangStr, darkModeEnabled, updateFavicon } from "../app/utils";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import routes from "./routes";
|
||||
import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
|
||||
@@ -21,7 +21,7 @@ import Login from "./Login";
|
||||
import Signup from "./Signup";
|
||||
import Account from "./Account";
|
||||
import initI18n from "../app/i18n"; // Translations!
|
||||
import prefs, { THEME } from "../app/Prefs";
|
||||
import prefs from "../app/Prefs";
|
||||
import RTLCacheProvider from "./RTLCacheProvider";
|
||||
import session from "../app/Session";
|
||||
|
||||
@@ -29,20 +29,6 @@ initI18n();
|
||||
|
||||
export const AccountContext = createContext(null);
|
||||
|
||||
const darkModeEnabled = (prefersDarkMode, themePreference) => {
|
||||
switch (themePreference) {
|
||||
case THEME.DARK:
|
||||
return true;
|
||||
|
||||
case THEME.LIGHT:
|
||||
return false;
|
||||
|
||||
case THEME.SYSTEM:
|
||||
default:
|
||||
return prefersDarkMode;
|
||||
}
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const languageDir = i18n.dir();
|
||||
@@ -97,6 +83,7 @@ const App = () => {
|
||||
const updateTitle = (newNotificationsCount) => {
|
||||
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
||||
window.navigator.setAppBadge?.(newNotificationsCount);
|
||||
updateFavicon(newNotificationsCount);
|
||||
};
|
||||
|
||||
const Layout = () => {
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
maybeActionErrors,
|
||||
openUrl,
|
||||
shortUrl,
|
||||
topicShortUrl,
|
||||
topicUrl,
|
||||
unmatchedTags,
|
||||
} from "../app/utils";
|
||||
import { ACTION_BROADCAST, ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "../app/actions";
|
||||
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
|
||||
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import notifier from "../app/Notifier";
|
||||
import priority1 from "../img/priority-1.svg";
|
||||
import priority2 from "../img/priority-2.svg";
|
||||
import priority4 from "../img/priority-4.svg";
|
||||
@@ -188,7 +190,7 @@ const MarkdownContainer = styled("div")`
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.2;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
@@ -303,7 +305,7 @@ const NotificationItem = (props) => {
|
||||
{formatTitle(notification)}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
|
||||
<Typography variant="body1" sx={{ whiteSpace: "pre-line", overflowX: "auto" }}>
|
||||
<NotificationBody notification={notification} />
|
||||
{maybeActionErrors(notification)}
|
||||
</Typography>
|
||||
@@ -344,7 +346,7 @@ const NotificationItem = (props) => {
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{hasUserActions && <UserActions notification={notification} />}
|
||||
{hasUserActions && <UserActions notification={notification} onShowSnack={props.onShowSnack} />}
|
||||
</CardActions>
|
||||
)}
|
||||
</Card>
|
||||
@@ -486,7 +488,7 @@ const Image = (props) => {
|
||||
const UserActions = (props) => (
|
||||
<>
|
||||
{props.notification.actions.map((action) => (
|
||||
<UserAction key={action.id} notification={props.notification} action={action} />
|
||||
<UserAction key={action.id} notification={props.notification} action={action} onShowSnack={props.onShowSnack} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -508,6 +510,15 @@ const updateActionStatus = (notification, action, progress, error) => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearNotification = async (notification) => {
|
||||
console.log(`[Notifications] Clearing notification ${notification.id}`);
|
||||
const subscription = await subscriptionManager.get(notification.subscriptionId);
|
||||
if (subscription) {
|
||||
await notifier.cancel(subscription, notification);
|
||||
}
|
||||
await subscriptionManager.markNotificationRead(notification.id);
|
||||
};
|
||||
|
||||
const performHttpAction = async (notification, action) => {
|
||||
console.log(`[Notifications] Performing HTTP user action`, action);
|
||||
try {
|
||||
@@ -523,6 +534,9 @@ const performHttpAction = async (notification, action) => {
|
||||
const success = response.status >= 200 && response.status <= 299;
|
||||
if (success) {
|
||||
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
|
||||
if (action.clear) {
|
||||
await clearNotification(notification);
|
||||
}
|
||||
} else {
|
||||
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
|
||||
}
|
||||
@@ -536,7 +550,7 @@ const UserAction = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { notification } = props;
|
||||
const { action } = props;
|
||||
if (action.action === "broadcast") {
|
||||
if (action.action === ACTION_BROADCAST) {
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_not_supported")}>
|
||||
<span>
|
||||
@@ -547,11 +561,17 @@ const UserAction = (props) => {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (action.action === "view") {
|
||||
if (action.action === ACTION_VIEW) {
|
||||
const handleClick = () => {
|
||||
openUrl(action.url);
|
||||
if (action.clear) {
|
||||
clearNotification(notification);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
|
||||
<Button
|
||||
onClick={() => openUrl(action.url)}
|
||||
onClick={handleClick}
|
||||
aria-label={t("notifications_actions_open_url_title", {
|
||||
url: action.url,
|
||||
})}
|
||||
@@ -561,7 +581,7 @@ const UserAction = (props) => {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (action.action === "http") {
|
||||
if (action.action === ACTION_HTTP) {
|
||||
const method = action.method ?? "POST";
|
||||
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
||||
return (
|
||||
@@ -583,12 +603,28 @@ const UserAction = (props) => {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (action.action === ACTION_COPY) {
|
||||
const handleClick = async () => {
|
||||
await copyToClipboard(action.value);
|
||||
props.onShowSnack();
|
||||
if (action.clear) {
|
||||
await clearNotification(notification);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<Button onClick={handleClick} aria-label={t("common_copy_to_clipboard")}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return null; // Others
|
||||
};
|
||||
|
||||
const NoNotifications = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
||||
const topicUrlResolved = topicUrl(props.subscription.baseUrl, props.subscription.topic);
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
@@ -601,7 +637,7 @@ const NoNotifications = (props) => {
|
||||
{t("notifications_example")}:<br />
|
||||
<tt>
|
||||
{'$ curl -d "Hi" '}
|
||||
{topicShortUrlResolved}
|
||||
{topicUrlResolved}
|
||||
</tt>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
@@ -614,7 +650,7 @@ const NoNotifications = (props) => {
|
||||
const NoNotificationsWithoutSubscription = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const subscription = props.subscriptions[0];
|
||||
const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const topicUrlResolved = topicUrl(subscription.baseUrl, subscription.topic);
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
@@ -627,7 +663,7 @@ const NoNotificationsWithoutSubscription = (props) => {
|
||||
{t("notifications_example")}:<br />
|
||||
<tt>
|
||||
{'$ curl -d "Hi" '}
|
||||
{topicShortUrlResolved}
|
||||
{topicUrlResolved}
|
||||
</tt>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
|
||||
@@ -429,13 +429,23 @@ const UserDialog = (props) => {
|
||||
const [password, setPassword] = useState("");
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const editMode = props.user !== null;
|
||||
const baseUrlValid = baseUrl.length === 0 || validUrl(baseUrl);
|
||||
const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl);
|
||||
const baseUrlError = baseUrl.length > 0 && (!baseUrlValid || baseUrlExists);
|
||||
const addButtonEnabled = (() => {
|
||||
if (editMode) {
|
||||
return username.length > 0 && password.length > 0;
|
||||
}
|
||||
const baseUrlValid = validUrl(baseUrl);
|
||||
const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl);
|
||||
return baseUrlValid && !baseUrlExists && username.length > 0 && password.length > 0;
|
||||
return validUrl(baseUrl) && !baseUrlExists && username.length > 0 && password.length > 0;
|
||||
})();
|
||||
const baseUrlHelperText = (() => {
|
||||
if (baseUrl.length > 0 && !baseUrlValid) {
|
||||
return t("prefs_users_dialog_base_url_invalid");
|
||||
}
|
||||
if (baseUrlExists) {
|
||||
return t("prefs_users_dialog_base_url_exists");
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
const handleSubmit = async () => {
|
||||
props.onSubmit({
|
||||
@@ -467,6 +477,8 @@ const UserDialog = (props) => {
|
||||
type="url"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
error={baseUrlError}
|
||||
helperText={baseUrlHelperText}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
|
||||
@@ -51,7 +51,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotification = async (subscriptionId, notification) => {
|
||||
const handleNotification = async (subscription, notification) => {
|
||||
// This logic is (partially) duplicated in
|
||||
// - Android: SubscriberService::onNotificationReceived()
|
||||
// - Android: FirebaseService::onMessageReceived()
|
||||
@@ -59,20 +59,20 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
||||
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
|
||||
|
||||
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
|
||||
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id);
|
||||
await notifier.cancel(notification);
|
||||
await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id);
|
||||
await notifier.cancel(subscription, notification);
|
||||
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
|
||||
await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id);
|
||||
await notifier.cancel(notification);
|
||||
await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id);
|
||||
await notifier.cancel(subscription, notification);
|
||||
} else {
|
||||
// Regular message: delete existing and add new
|
||||
const sequenceId = notification.sequence_id || notification.id;
|
||||
if (sequenceId) {
|
||||
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId);
|
||||
await subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId);
|
||||
}
|
||||
const added = await subscriptionManager.addNotification(subscriptionId, notification);
|
||||
const added = await subscriptionManager.addNotification(subscription.id, notification);
|
||||
if (added) {
|
||||
await subscriptionManager.notify(subscriptionId, notification);
|
||||
await subscriptionManager.notify(subscription.id, notification);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -89,7 +89,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
||||
if (subscription.internal) {
|
||||
await handleInternalMessage(message);
|
||||
} else {
|
||||
await handleNotification(subscriptionId, message);
|
||||
await handleNotification(subscription, message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
188
webpush/store.go
Normal 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
@@ -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()
|
||||
}
|
||||
91
webpush/store_postgres_test.go
Normal 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
@@ -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
|
||||
}
|
||||
63
webpush/store_sqlite_test.go
Normal 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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||