Compare commits

..
Author SHA1 Message Date
binwiederhier 6219784aae Bump 2026-03-30 16:38:01 -04:00
binwiederhier 51da5e0f77 Review 2026-03-30 16:33:19 -04:00
binwiederhier e57ef84f13 Fix limits for anon users 2026-03-30 16:06:53 -04:00
binwiederhier 4c6225e311 Docs 2026-03-30 12:38:47 -04:00
binwiederhier 07b3812549 Docs, fix generator 2026-03-30 12:37:07 -04:00
binwiederhier 63ec73a319 Tests 2026-03-30 12:21:19 -04:00
binwiederhier bdea8c314f Refine docs and bump deps 2026-03-30 09:00:47 -04:00
binwiederhier 3e634e0a5a Refine 2026-03-30 08:51:18 -04:00
binwiederhier 6aebc5c677 Refine 2026-03-30 08:26:56 -04:00
binwiederhier 61dd788dac WIP: Email verification 2026-03-29 22:47:38 -04:00
Philipp C. Heckel 266d0b9d37 Merge pull request #1679 from binwiederhier/attachment-no-http2
Attachment: Disable HTTP/2
2026-03-27 14:21:06 -04:00
binwiederhier 835d1faac4 Bump 2026-03-27 14:14:31 -04:00
binwiederhier 67fc7fe96a Disable HTTP2 for S3 backend with ?disable_http2=true option 2026-03-27 13:59:07 -04:00
binwiederhier 92fa88cf12 Merge branch 'main' into attachment-no-http2 2026-03-27 10:04:42 -04:00
binwiederhier b5fee121d7 Empty commit 2026-03-26 22:58:24 -04:00
binwiederhier e1a344339f Dummy 2026-03-26 22:20:07 -04:00
binwiederhier ae1ecfa1e9 BUmp 2026-03-26 22:17:54 -04:00
binwiederhier 874bdcf9f1 No HTTP/2 2026-03-26 20:47:06 -04:00
binwiederhier 2235d44726 Update changelog 2026-03-26 13:39:04 -04:00
binwiederhier 27bbb10a31 Bump 2026-03-26 11:13:43 -04:00
binwiederhier be4134fc3b Try to fix flaky again 2026-03-26 10:49:39 -04:00
binwiederhier e19ba059b5 Make attachment.Close() synchronous 2026-03-26 09:57:06 -04:00
binwiederhier 11a14d8fe7 Fix CI 2026-03-26 09:36:34 -04:00
binwiederhier 3759ff26b4 "npm ci" 2026-03-26 09:28:05 -04:00
binwiederhier 136b50f926 I'm tired and GitHub Actions hates me. I'll release this tomorrow ... 2026-03-25 21:35:51 -04:00
binwiederhier 2770f65027 Arrrg 2026-03-25 21:12:27 -04:00
binwiederhier db6f813386 Release notes derp 2026-03-25 21:03:49 -04:00
binwiederhier 15d963cb53 Fix flaky test due to new attachment expiry cap logic 2026-03-25 21:00:58 -04:00
binwiederhier a2206dba9f Fix races in tests 2026-03-25 20:47:20 -04:00
binwiederhier d159580ecf Bump 2026-03-25 20:34:48 -04:00
binwiederhier 0de9dc11ad Bump 2026-03-25 20:29:12 -04:00
binwiederhier f790143b0b Refined wording, review 2026-03-25 17:35:24 -04:00
binwiederhier 42b0254c9b Merge branch 'main' of github.com:binwiederhier/ntfy into ageru/main 2026-03-25 17:26:49 -04:00
Philipp C. Heckel d183af61fa Merge pull request #1672 from binwiederhier/attachment-fixes
Attachment fixes to address inconsistencies between DB and backend store
2026-03-25 17:26:18 -04:00
ageru e22a77d4bb Rename ntfy-server.openrc to ntfy.openrc 2026-03-24 23:38:16 +01:00
ageru e0362dce36 install.md - Install ntfy server service manually
Rework of steps to install ntfy server service on systemd and OpenRC
2026-03-24 23:36:19 +01:00
ageru 676f1ff1cb Merge branch 'binwiederhier:main' into main 2026-03-24 22:37:38 +01:00
ageru e8199fa6b5 install.md - Install ntfy server service manually
First draft - Add manual steps to install ntfy server service on systemd and OpenRC
2026-03-16 23:30:21 +01:00
ageru c29a7bc8cc Merge branch 'binwiederhier:main' into main 2026-03-16 22:35:14 +01:00
ageru 78f0593abe Merge pull request #1 from ageru/add-openrc-init-file-for-ntfy-server
Create Init service file for OpenRC
2026-03-11 22:00:03 +01:00
ageru aca58f040f Create Init service file for OpenRC
This in an init file for OpenRC systems.

It should be equivalent in features to the current systemd file, with 2 deliberate changes:
- removed "no-log-dates", as the logs are fine as-is
- Lower nofile limit, as it seems largely sufficient for a self-hosted instance. Feel free to increase to 8192 or 10240 if necessary.

Confirmed functional with Gentoo amd64 and ntfy 2.17.0.
2026-03-11 21:56:14 +01:00
42 changed files with 1543 additions and 277 deletions
+3 -1
View File
@@ -146,11 +146,13 @@ web-build:
../server/site/config.js
web-deps:
cd web && $(NPM) install
cd web && $(NPM) ci
# Use "npm ci" so that we don't change the package lock file
# If this fails for .svg files, optimize them with svgo
web-deps-update:
cd web && $(NPM) update
cd web && $(NPM) install
web-fmt:
cd web && $(NPM) run format
+8 -2
View File
@@ -30,6 +30,7 @@ type Store struct {
attachmentsWithSizes func() (map[string]int64, error) // Returns file ID -> size for active attachments
orphanGracePeriod time.Duration // Don't delete orphaned objects younger than this
closeChan chan struct{}
doneChan chan struct{}
mu sync.RWMutex // Protects size and sizes
}
@@ -44,7 +45,7 @@ func NewFileStore(dir string, totalSizeLimit int64, orphanGracePeriod time.Durat
// NewS3Store creates a new S3-backed attachment cache. The s3URL must be in the format:
//
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT]
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
func NewS3Store(s3URL string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
config, err := s3.ParseURL(s3URL)
if err != nil {
@@ -61,6 +62,7 @@ func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Dura
attachmentsWithSizes: attachmentsWithSizes,
orphanGracePeriod: orphanGracePeriod,
closeChan: make(chan struct{}),
doneChan: make(chan struct{}),
}
// Hydrate sizes from the database immediately so that Size()/Remaining()/Remove()
// are accurate from the start, without waiting for the first sync() call.
@@ -74,6 +76,8 @@ func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Dura
c.size += size
}
go c.syncLoop()
} else {
close(c.doneChan)
}
return c, nil
}
@@ -216,12 +220,14 @@ func (c *Store) Remaining() int64 {
return remaining
}
// Close stops the background sync goroutine
// Close stops the background sync goroutine and waits for it to finish
func (c *Store) Close() {
close(c.closeChan)
<-c.doneChan
}
func (c *Store) syncLoop() {
defer close(c.doneChan)
if err := c.sync(); err != nil {
log.Tag(tagStore).Err(err).Warn("Attachment sync failed")
}
+5
View File
@@ -71,6 +71,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-verify", Aliases: []string{"smtp_sender_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
@@ -184,6 +185,7 @@ func execServe(c *cli.Context) error {
smtpSenderUser := c.String("smtp-sender-user")
smtpSenderPass := c.String("smtp-sender-pass")
smtpSenderFrom := c.String("smtp-sender-from")
smtpSenderVerify := c.Bool("smtp-sender-verify")
smtpServerListen := c.String("smtp-server-listen")
smtpServerDomain := c.String("smtp-server-domain")
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
@@ -310,6 +312,8 @@ func execServe(c *cli.Context) error {
return errors.New("if listen-https is set, both key-file and cert-file must be set")
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
} else if smtpSenderVerify && smtpSenderAddr == "" {
return errors.New("if smtp-sender-verify is set, smtp-sender-addr must also be set")
} else if smtpServerListen != "" && smtpServerDomain == "" {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
@@ -471,6 +475,7 @@ func execServe(c *cli.Context) error {
conf.SMTPSenderUser = smtpSenderUser
conf.SMTPSenderPass = smtpSenderPass
conf.SMTPSenderFrom = smtpSenderFrom
conf.SMTPSenderVerify = smtpSenderVerify
conf.SMTPServerListen = smtpServerListen
conf.SMTPServerDomain = smtpServerDomain
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
+29 -5
View File
@@ -353,6 +353,14 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
<label>SMTP password</label>
<input type="password" data-key="smtp-sender-pass" placeholder="Password">
</div>
<div class="cg-field cg-inline-field">
<label>Require email verification</label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-smtp-sender-verify" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-smtp-sender-verify" value="yes"><span>Yes</span></label>
</div>
</div>
<input type="checkbox" data-key="smtp-sender-verify" id="cg-smtp-sender-verify-hidden" style="display:none">
</div>
<div id="cg-email-in-section" class="cg-hidden">
<div class="cg-field"><label><strong>Incoming (publishing)</strong></label></div>
@@ -538,7 +546,7 @@ As an alternative to the local filesystem, you can store attachments in an S3-co
To use an S3-compatible storage for attachments, set `attachment-cache-dir` to an S3 URL with the following format:
```
s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT]
s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
```
Here are a few examples:
@@ -546,7 +554,7 @@ Here are a few examples:
=== "/etc/ntfy/server.yml (DigitalOcean Spaces)"
``` yaml
base-url: "https://ntfy.example.com"
attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=nyc3&endpoint=https://nyc3.digitaloceanspaces.com"
attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=nyc3&endpoint=https://nyc3.digitaloceanspaces.com&disable_http2=true"
```
=== "/etc/ntfy/server.yml (AWS S3)"
@@ -564,6 +572,9 @@ Here are a few examples:
Note that the access key and secret key may have to be URL encoded. For instance, a secret key `YmxhY+mxhYmxhC` (note the `+`) should
be encoded as `YmxhY%2BmxhYmxhC` (note the `%2B`), so the URL would be `s3://ACCESS_KEY:YmxhY%2BmxhYmxhC@my-bucket/attachments...`.
If you experience upload failures with HTTP/2 stream errors (common with DigitalOcean Spaces and some other S3-compatible providers),
add `&disable_http2=true` to force HTTP/1.1 connections.
!!! info
ntfy.sh is hosted and sponsored by DigitalOcean. I can highly recommend their public cloud offering. It's been rock solid
for 4 years. They offer an S3-compatible storage for $5/month and 250 GB of storage, with 1 TiB of bandwidth.
@@ -1008,6 +1019,10 @@ To allow forwarding messages via e-mail, you can configure an **SMTP server for
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
`curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`).
!!! info
On ntfy.sh, anonymous email sending was disabled due to abuse. To use the email notification feature,
you must verify your email in the web app's [Account section](https://ntfy.sh/account).
As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the
following settings:
@@ -1015,6 +1030,8 @@ following settings:
* `smtp-sender-addr` is the hostname:port of the SMTP server
* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user
* `smtp-sender-from` is the e-mail address of the sender
* `smtp-sender-verify` is a flag that forces email recipient verification when enabled. If set to true,
only verified email recipients can be used in the `X-Email` header.
Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is
configured for `ntfy.sh`):
@@ -1026,9 +1043,15 @@ configured for `ntfy.sh`):
smtp-sender-user: "AKIDEADBEEFAFFE12345"
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
smtp-sender-from: "ntfy@ntfy.sh"
smtp-sender-verify: true
```
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
By default, any user (including anonymous users) can send email notifications to any address. To require email
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails,
and authenticated users can only send to email addresses they have verified in their account settings. Users can
also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address.
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
## E-mail publishing
@@ -2189,7 +2212,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory or S3 URL* | - | Cache directory for attached files, or S3 URL for object storage (format: `s3://KEY:SECRET@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT]`). |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory or S3 URL* | - | Cache directory for attached files, or S3 URL for object storage (format: `s3://KEY:SECRET@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]`). |
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
@@ -2197,6 +2220,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
@@ -2291,7 +2315,7 @@ OPTIONS:
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT]) [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]) [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: "3h") [$NTFY_ATTACHMENT_EXPIRY_DURATION]
+118 -30
View File
@@ -28,42 +28,130 @@ resources to get started. _I am not affiliated with Kris or Alex, I just liked t
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
deb/rpm packages.
### Download and run
The steps below allow you to download ntfy server and run it in a pinch. But it won't be enough to install it permanently
as a service starting at boot time.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.tar.gz
tar zxvf ntfy_2.19.2_linux_amd64.tar.gz
sudo cp -a ntfy_2.19.2_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.tar.gz
tar zxvf ntfy_2.21.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.21.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.tar.gz
tar zxvf ntfy_2.19.2_linux_armv6.tar.gz
sudo cp -a ntfy_2.19.2_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.tar.gz
tar zxvf ntfy_2.21.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.21.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.tar.gz
tar zxvf ntfy_2.19.2_linux_armv7.tar.gz
sudo cp -a ntfy_2.19.2_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.tar.gz
tar zxvf ntfy_2.21.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.21.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.tar.gz
tar zxvf ntfy_2.19.2_linux_arm64.tar.gz
sudo cp -a ntfy_2.19.2_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.tar.gz
tar zxvf ntfy_2.21.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.21.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
### Install as a service
If you want to install ntfy server permanently as a service, and your OS/distribution of choice doesn't offer a package,
there are a few more steps to follow.
Create the ntfy user and group:
```bash
useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy
```
Depending on your init system, the following steps will diverge.
#### On systemd systems
Install the ntfy server unit file (which contains parameters to start the service at boot time):
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.21.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv6"
```bash
sudo mv ntfy_2.21.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.21.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "arm64"
```bash
sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
Then notify systemd we have added a new service and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl start ntfy
```
#### On OpenRC systems
Install the ntfy server service script:
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.21.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv6"
```bash
sudo mv ntfy_2.21.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.21.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "arm64"
```bash
sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
Start the ntfy server service:
```bash
sudo rc-service ntfy start
```
Add the ntfy server service to the default runlevel (so that it starts at boot time):
```bash
sudo rc-update add ntfy default
```
## Debian/Ubuntu repository
!!! info
@@ -116,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -124,7 +212,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -132,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,7 +228,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -150,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.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.19.2/ntfy_2.19.2_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.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.19.2/ntfy_2.19.2_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.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.19.2/ntfy_2.19.2_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -213,18 +301,18 @@ pkg install go-ntfy
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.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.19.2/ntfy_2.19.2_darwin_all.tar.gz > ntfy_2.19.2_darwin_all.tar.gz
tar zxvf ntfy_2.19.2_darwin_all.tar.gz
sudo cp -a ntfy_2.19.2_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_darwin_all.tar.gz > ntfy_2.21.0_darwin_all.tar.gz
tar zxvf ntfy_2.21.0_darwin_all.tar.gz
sudo cp -a ntfy_2.21.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.19.2_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.21.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -245,7 +333,7 @@ brew install ntfy
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
To install, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_windows_amd64.zip),
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.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`
+11 -5
View File
@@ -3213,12 +3213,18 @@ You can forward messages to e-mail by specifying an address in the header. This
you'd like to persist longer, or to blast-notify yourself on all possible channels.
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
Only one e-mail address is supported.
Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled),
you can also pass `yes`, `true`, or `1` to send to your first verified email address.
Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
that, your IP address appears in the e-mail body. This is to prevent abuse.
!!! info
On ntfy.sh, anonymous email sending was disabled due to abuse. To use the email notification feature,
you must verify your email in the web app's [Account section](https://ntfy.sh/account). The daily limit for
free users is **5 emails per visitor per day**.
=== "Command line (curl)"
```
curl \
@@ -3658,7 +3664,7 @@ all the supported fields:
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
@@ -4871,7 +4877,7 @@ table in their canonical form.
| `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body |
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address (or `yes`) for [e-mail notifications](#e-mail-notifications) |
| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
+50 -20
View File
@@ -6,12 +6,58 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.19.2 | Mar 16, 2026 |
| ntfy server | v2.21.0 | Mar 30, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 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.21.0
Released March 30, 2026
This release adds the ability to verify email addresses using the `smtp-sender-verify` flag. This is a change that is
required because ntfy.sh was used to send unsolicited emails and the AWS SES account was suspended. Going forward,
ntfy.sh won't be able to send emails unless the email address was verified ahead of time.
**Features:**
* Add verified email recipients feature with `smtp-sender-verify` config flag, allowing server admins to require email
address verification before sending email notifications ([#1681](https://github.com/binwiederhier/ntfy/pull/1681))
### ntfy server v2.20.1
Released March 27, 2026
This is a small bugfix release that only affects high volume S3 backends that struggle with HTTP/2.
**Bug fixes + maintenance:**
* [Attachments](config.md#attachments): Add `disable_http2=true` S3 URL option to work around HTTP/2 stream errors with DigitalOcean Spaces and other S3-compatible providers ([#1678](https://github.com/binwiederhier/ntfy/issues/1678)/[#1679](https://github.com/binwiederhier/ntfy/pull/1679))
### ntfy server v2.20.0
Released March 26, 2026
This release is another step towards making it possible to help scale ntfy up and out 🔥! With this release, you can store
attachments in an S3-compatible object store as an alterative to the directory. See [attachment store](config.md#attachments)
for details.
!!! warning
With this release, ntfy will take full control over the attachment directory or S3 bucket. Files/objects in the configured `attachment-cache-dir`
that match the message ID format (12 chars, matching `^[A-Za-z0-9]{12}$`), and have no entries in the message database will be deleted.
**Do not use a directory or S3 bucket as `attachment-cache-dir` that is also used for something else.**
This is a small behavioral change that was necessary because the old logic often left attachments behind and would not clean them
up. Unless you have re-used the attachment directory for anything else (which is hopefully never done), this should not affect
you at all.
**Features:**
* Add S3-compatible object storage as an alternative [attachment store](config.md#attachments) via `attachment-cache-dir` config option ([#1656](https://github.com/binwiederhier/ntfy/pull/1656)/[#1672](https://github.com/binwiederhier/ntfy/pull/1672))
**Bug fixes + maintenance:**
* Reject invalid e-mail addresses (e.g. multiple comma-separated recipients) with HTTP 400
* Add OpenRC init service file ([#1650](https://github.com/binwiederhier/ntfy/pull/1650), thanks to [@ageru](https://github.com/ageru) for the contribution)
## ntfy server v2.19.2
Released March 16, 2026
@@ -1798,25 +1844,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.20.x (UNRELEASED)
This release is another step towards making it possible to help scale ntfy up and out 🔥! With this release, you can store
attachments in an S3-compatible object store as an alterative to the directory. See [attachment store](config.md#attachments)
for details.
!!! warning
With this release, ntfy will take full control over the attachment directory or S3 bucket. Files/objects in the configured `attachment-cache-dir`
that match the message ID format (12 chars, matching `^[A-Za-z0-9]{12}$`), and have no entries in the message database will be deleted.
**Do not use a directory or S3 bucket as `attachment-cache-dir` that is also used for something else.**
This is a small behavioral change that was necessary because the old logic often left attachments behind and would not clean them
up. Unless you have re-used the attachment directory for anything else (which is hopefully never done), this should not affect
you at all.
## ntfy Android v1.25.x (UNRELEASED)
**Features:**
* Add S3-compatible object storage as an alternative [attachment store](config.md#attachments) via `attachment-cache-dir` config option ([#1656](https://github.com/binwiederhier/ntfy/pull/1656)/[#1672](https://github.com/binwiederhier/ntfy/pull/1672))
**Bug fixes + maintenance:**
* Reject invalid e-mail addresses (e.g. multiple comma-separated recipients) with HTTP 400
* Add configurable "Alert when connection is lost" setting ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Suppress connection alerts and stop foreground service when there is no network ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution)
+6
View File
@@ -125,6 +125,7 @@
{ key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" },
{ key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" },
{ key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" },
{ key: "smtp-sender-verify", env: "NTFY_SMTP_SENDER_VERIFY", section: "smtp-out", type: "bool" },
{ key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" },
{ key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" },
{ key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" },
@@ -171,6 +172,7 @@
requireLoginHidden: modal.querySelector("#cg-require-login-hidden"),
signupHidden: modal.querySelector("#cg-enable-signup-hidden"),
proxyCheckbox: modal.querySelector("#cg-behind-proxy"),
smtpSenderVerifyHidden: modal.querySelector("#cg-smtp-sender-verify-hidden"),
dbStep: modal.querySelector("#cg-wizard-db"),
navDb: modal.querySelector("#cg-nav-database"),
navEmail: modal.querySelector("#cg-nav-email"),
@@ -743,6 +745,10 @@
const signupYes = modal.querySelector("input[name=\"cg-enable-signup\"][value=\"yes\"]");
if (signupYes && signupHidden) signupHidden.checked = signupYes.checked;
// SMTP sender verify radio → hidden checkbox
const smtpVerifyYes = modal.querySelector("input[name=\"cg-smtp-sender-verify\"][value=\"yes\"]");
if (smtpVerifyYes && els.smtpSenderVerifyHidden) els.smtpSenderVerifyHidden.checked = smtpVerifyYes.checked;
return loginModeVal;
}
+6 -6
View File
@@ -10,7 +10,7 @@ require (
github.com/emersion/go-smtp v0.18.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.37
github.com/mattn/go-sqlite3 v1.14.38
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
@@ -19,7 +19,7 @@ require (
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
golang.org/x/time v0.15.0
google.golang.org/api v0.272.0
google.golang.org/api v0.273.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -30,7 +30,7 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.19.0
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.9.0
github.com/jackc/pgx/v5 v5.9.1
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
@@ -41,10 +41,10 @@ require (
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 // indirect
cloud.google.com/go/auth v0.19.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/iam v1.6.0 // 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
@@ -70,7 +70,7 @@ require (
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
github.com/googleapis/gax-go/v2 v2.20.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
+12 -12
View File
@@ -2,16 +2,16 @@ 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.3-0.20260310051336-87cdcc9f7568 h1:PJt3KrySfZkKdcEV2wlyNkfAPbMZGjtnv5oLrT4tWPg=
cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568/go.mod h1:/Tt0rLCp4FHXEBtdyYqvIZPcJzbpJ/fmqtgIaXseDK4=
cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/iam v1.6.0 h1:JiSIcEi38dWBKhB3BtfKCW+dMvCZJEhBA2BsaGJgoxs=
cloud.google.com/go/iam v1.6.0/go.mod h1:ZS6zEy7QHmcNO18mjO2viYv/n+wOUkhJqGNkPPGueGU=
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
@@ -98,8 +98,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
github.com/googleapis/gax-go/v2 v2.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs=
github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
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=
@@ -108,8 +108,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
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.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -120,8 +120,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -272,8 +272,8 @@ 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.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
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-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
+154
View File
@@ -0,0 +1,154 @@
package mail
import (
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
const (
verifyCodeExpiry = 10 * time.Minute
verifyCodeLength = 6
verifyCodeSubject = "ntfy email verification"
)
// Config holds the SMTP configuration for the mail sender
type Config struct {
SMTPAddr string // SMTP server address (host:port)
SMTPUser string // SMTP auth username
SMTPPass string // SMTP auth password
From string // Sender email address
}
// Sender sends emails and manages email verification codes
type Sender struct {
config *Config
codes map[string]verifyCode // Verification codes, keyed by email
mu sync.Mutex
closeChan chan struct{}
}
type verifyCode struct {
code string
expires time.Time
}
// NewSender creates a new mail Sender with the given SMTP config
func NewSender(config *Config) *Sender {
s := &Sender{
config: config,
codes: make(map[string]verifyCode),
closeChan: make(chan struct{}),
}
go s.expireLoop()
return s
}
// Close stops the background expiry loop
func (s *Sender) Close() {
close(s.closeChan)
}
// Addr returns the SMTP server address
func (s *Sender) Addr() string {
return s.config.SMTPAddr
}
// User returns the SMTP username
func (s *Sender) User() string {
return s.config.SMTPUser
}
// From returns the sender email address
func (s *Sender) From() string {
return s.config.From
}
// SendRaw sends a raw email message via SMTP
func (s *Sender) SendRaw(to string, message []byte) error {
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
if err != nil {
return err
}
var auth smtp.Auth
if s.config.SMTPUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
}
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
}
// Send sends a plain text email via SMTP
func (s *Sender) Send(to, subject, body string) error {
date := time.Now().UTC().Format(time.RFC1123Z)
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
message := `From: ntfy <{from}>
To: {to}
Date: {date}
Subject: {subject}
Content-Type: text/plain; charset="utf-8"
{body}`
message = strings.ReplaceAll(message, "{from}", s.config.From)
message = strings.ReplaceAll(message, "{to}", to)
message = strings.ReplaceAll(message, "{date}", date)
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
message = strings.ReplaceAll(message, "{body}", body)
log.Tag("mail").Field("email_to", to).Debug("Sending email")
return s.SendRaw(to, []byte(message))
}
// SendVerification generates a random code, stores it in-memory, and sends a verification email
func (s *Sender) SendVerification(to string) error {
code := util.RandomString(verifyCodeLength)
s.mu.Lock()
s.codes[to] = verifyCode{
code: code,
expires: time.Now().Add(verifyCodeExpiry),
}
s.mu.Unlock()
body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
return s.Send(to, verifyCodeSubject, body)
}
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
func (s *Sender) CheckVerification(email, code string) bool {
s.mu.Lock()
defer s.mu.Unlock()
vc, ok := s.codes[email]
if !ok || time.Now().After(vc.expires) || vc.code != code {
return false
}
delete(s.codes, email)
return true
}
func (s *Sender) expireLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.expireVerificationCodes()
case <-s.closeChan:
return
}
}
}
func (s *Sender) expireVerificationCodes() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for email, vc := range s.codes {
if now.After(vc.expires) {
delete(s.codes, email)
}
}
}
+23 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/md5" //nolint:gosec // MD5 is required by the S3 protocol for Content-MD5 headers
"crypto/tls"
"encoding/base64"
"encoding/xml"
"errors"
@@ -61,7 +62,11 @@ type Client struct {
func New(config *Config) *Client {
httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
if config.DisableHTTP2 {
httpClient = newHTTP1Client()
} else {
httpClient = http.DefaultClient
}
}
return &Client{
config: config,
@@ -300,3 +305,20 @@ func (c *Client) do(ctx context.Context, op, method, reqURL string, body []byte,
}
return respBody, nil
}
// newHTTP1Client creates an HTTP client that forces HTTP/1.1 by disabling HTTP/2
// ALPN negotiation. This works around HTTP/2 stream errors with some S3-compatible
// providers (e.g. DigitalOcean Spaces) that can cause non-retryable failures on
// streaming uploads when the server resets the stream mid-transfer.
// See https://github.com/rclone/rclone/issues/4673, https://github.com/golang/go/issues/42777
func newHTTP1Client() *http.Client {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
ForceAttemptHTTP2: false,
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
},
}
}
+12
View File
@@ -92,6 +92,18 @@ func TestParseURL_EmptyBucket(t *testing.T) {
require.Contains(t, err.Error(), "bucket")
}
func TestParseURL_DisableHTTP2(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&disable_http2=true")
require.Nil(t, err)
require.True(t, cfg.DisableHTTP2)
}
func TestParseURL_DisableHTTP2_NotSet(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1")
require.Nil(t, err)
require.False(t, cfg.DisableHTTP2)
}
// --- Unit tests: URL construction ---
func TestConfig_BucketURL_PathStyle(t *testing.T) {
+9 -8
View File
@@ -11,14 +11,15 @@ import (
// Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string.
type Config struct {
Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com"
PathStyle bool
Bucket string
Prefix string
Region string
AccessKey string
SecretKey string
HTTPClient *http.Client // if nil, http.DefaultClient is used
Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com"
PathStyle bool
Bucket string
Prefix string
Region string
AccessKey string
SecretKey string
DisableHTTP2 bool // Force HTTP/1.1 to work around HTTP/2 issues with some S3-compatible providers
HTTPClient *http.Client // if nil, a default client is created (respecting DisableHTTP2)
}
// BucketURL returns the base URL for bucket-level operations.
+13 -8
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"sort"
"strconv"
"strings"
)
@@ -41,9 +42,11 @@ const (
// ParseURL parses an S3 URL of the form:
//
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT]
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
//
// When endpoint is specified, path-style addressing is enabled automatically.
// When disable_http2=true is set, the client forces HTTP/1.1 to work around
// HTTP/2 stream errors with some S3-compatible providers (e.g. DigitalOcean Spaces).
func ParseURL(s3URL string) (*Config, error) {
u, err := url.Parse(s3URL)
if err != nil {
@@ -80,14 +83,16 @@ func ParseURL(s3URL string) (*Config, error) {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region)
pathStyle = false
}
disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2"))
return &Config{
Endpoint: endpoint,
PathStyle: pathStyle,
Bucket: bucket,
Prefix: prefix,
Region: region,
AccessKey: accessKey,
SecretKey: secretKey,
Endpoint: endpoint,
PathStyle: pathStyle,
Bucket: bucket,
Prefix: prefix,
Region: region,
AccessKey: accessKey,
SecretKey: secretKey,
DisableHTTP2: disableHTTP2,
}, nil
}
+2
View File
@@ -135,6 +135,7 @@ type Config struct {
SMTPSenderUser string
SMTPSenderPass string
SMTPSenderFrom string
SMTPSenderVerify bool
SMTPServerListen string
SMTPServerDomain string
SMTPServerAddrPrefix string
@@ -239,6 +240,7 @@ func NewConfig() *Config {
SMTPSenderUser: "",
SMTPSenderPass: "",
SMTPSenderFrom: "",
SMTPSenderVerify: false,
SMTPServerListen: "",
SMTPServerDomain: "",
SMTPServerAddrPrefix: "",
+4
View File
@@ -143,6 +143,9 @@ var (
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil}
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
@@ -152,6 +155,7 @@ var (
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
+49
View File
@@ -0,0 +1,49 @@
#!/sbin/openrc-run
# OpenRC service configuration for ntfy Server.
# Should be placed in /etc/init.d/ as "ntfy" or "ntfy-server" (no extension), owned by root:root and with permissions 755.
# Assumes an ntfy system user and group have been created, for example using this command:
# useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy
name=$RC_SVCNAME
description="ntfy server"
command="/usr/local/bin/ntfy"
command_background=true
command_args="serve"
command_user="ntfy:ntfy"
extra_started_commands="reload"
pidfile="/run/${RC_SVCNAME}/${RC_SVCNAME}.pid"
# Changes the hard number of open files (nofile) limit to 2048 for the service.
rc_ulimit="-n 2048"
# Allows the service to bind to privileged ports (<1024).
capabilities="^cap_net_bind_service"
error_log="/var/log/ntfy.log"
# Service dependencies
depend() {
use net
after firewall
}
# Check for - and if necessary - create required files and folders. Might require some adjustment dependings on the content of the server.yml file.
start_pre() {
checkpath -f --owner "$command_user" --mode 0644 \
/var/log/ntfy.log
checkpath -d --owner "$command_user" --mode 0750 \
/run/ntfy/
checkpath -d --owner "$command_user" --mode 0755 \
/var/lib/ntfy/
checkpath -d --owner "$command_user" --mode 0750 \
/var/cache/ntfy/
}
reload() {
ebegin "Reloading $RC_SVCNAME's configuration"
start-stop-daemon --signal SIGHUP --pidfile "${pidfile}"
eend $? "Failed to reload $RC_SVCNAME's configuration"
}
+42 -8
View File
@@ -36,6 +36,7 @@ import (
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/payments"
@@ -57,6 +58,7 @@ type Server struct {
smtpServer *smtp.Server
smtpServerBackend *smtpBackend
smtpSender mailer
mailSender *mail.Sender
topics map[string]*topic
visitors map[string]*visitor // ip:<ip> or user:<user>
firebaseClient *firebaseClient
@@ -112,6 +114,8 @@ var (
apiAccountReservationPath = "/v1/account/reservation"
apiAccountPhonePath = "/v1/account/phone"
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
apiAccountEmailPath = "/v1/account/email"
apiAccountEmailVerifyPath = "/v1/account/email/verify"
apiAccountBillingPortalPath = "/v1/account/billing/portal"
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
@@ -173,8 +177,15 @@ const (
// subscriber (if configured).
func New(conf *Config) (*Server, error) {
var mailer mailer
var mailSender *mail.Sender
if conf.SMTPSenderAddr != "" {
mailer = &smtpSender{config: conf}
mailSender = mail.NewSender(&mail.Config{
SMTPAddr: conf.SMTPSenderAddr,
SMTPUser: conf.SMTPSenderUser,
SMTPPass: conf.SMTPSenderPass,
From: conf.SMTPSenderFrom,
})
mailer = &smtpSender{config: conf, sender: mailSender}
}
var stripe stripeAPI
if payments.Available && conf.StripeSecretKey != "" {
@@ -278,6 +289,7 @@ func New(conf *Config) (*Server, error) {
attachment: attachmentStore,
firebaseClient: firebaseClient,
smtpSender: mailer,
mailSender: mailSender,
topics: topics,
userManager: userManager,
messages: messages,
@@ -429,18 +441,25 @@ func (s *Server) Stop() {
if s.smtpServer != nil {
s.smtpServer.Close()
}
if s.mailSender != nil {
s.mailSender.Close()
}
if s.attachment != nil {
s.attachment.Close()
}
s.closeDatabases()
close(s.closeChan)
if s.closeChan != nil {
close(s.closeChan)
}
}
func (s *Server) closeDatabases() {
if s.userManager != nil {
s.userManager.Close()
}
s.messageCache.Close()
if s.messageCache != nil {
s.messageCache.Close()
}
if s.webPush != nil {
s.webPush.Close()
}
@@ -590,6 +609,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailVerifyPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v)
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
@@ -696,6 +721,7 @@ func (s *Server) configResponse() *apiConfigResponse {
EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "",
EnableEmailVerify: s.config.SMTPSenderVerify,
EnableReservations: s.config.EnableReservations,
EnableWebPush: s.config.WebPushPublicKey != "",
BillingContact: s.config.BillingContact,
@@ -861,9 +887,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
} else if email != "" && !vrate.EmailAllowed() {
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
} else if call != "" {
}
if email != "" {
var httpErr *errHTTP
email, httpErr = s.convertEmailAddress(v.User(), email)
if httpErr != nil {
return nil, httpErr.With(t)
} else if !vrate.EmailAllowed() {
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
}
}
if call != "" {
var httpErr *errHTTP
call, httpErr = s.convertPhoneNumber(v.User(), call)
if httpErr != nil {
@@ -1071,7 +1105,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
}
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email)
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
if err := s.smtpSender.Send(v, m, email); err != nil {
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
minc(metricEmailsPublishedFailure)
@@ -1169,7 +1203,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
m.Icon = icon
}
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if email != "" && !emailAddressRegex.MatchString(email) {
if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) {
return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid
}
if s.smtpSender == nil && email != "" {
+3
View File
@@ -193,11 +193,14 @@
# - smtp-sender-addr is the hostname:port of the SMTP server
# - smtp-sender-from is the e-mail address of the sender
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
# - smtp-sender-verify is a flag that forces email recipient verification when enabled. If set to true,
# only verified email recipients can be used in the X-Email header.
#
# smtp-sender-addr:
# smtp-sender-from:
# smtp-sender-user:
# smtp-sender-pass:
# smtp-sender-verify: false
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
# emails to a topic e-mail address to publish messages to a topic.
+106
View File
@@ -160,6 +160,15 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
response.PhoneNumbers = phoneNumbers
}
}
if s.mailSender != nil {
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return err
}
if len(emails) > 0 {
response.Emails = emails
}
}
} else {
response.Username = user.Everyone
response.Role = string(user.RoleAnonymous)
@@ -606,6 +615,103 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
// Check user is allowed to add emails
if u == nil {
return errHTTPUnauthorized
} else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
return errHTTPUnauthorized
} else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
return errHTTPUnauthorized
}
// Check if email already exists
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return err
} else if util.Contains(emails, req.Email) {
return errHTTPConflictEmailExists
}
// Check email rate limit (counts against the user's email quota)
if !v.EmailAllowed() {
return errHTTPTooManyRequestsLimitEmails
}
// Send verification email
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification")
if err := s.mailSender.SendVerification(req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
} else if !s.mailSender.CheckVerification(req.Email, req.Code) {
return errHTTPBadRequestEmailVerificationCodeInvalid
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified")
if err := s.userManager.AddEmail(u.ID, req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email")
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
// convertEmailAddress checks the email address against the user's verified email list.
// If smtp-sender-verify is false (default), the email is passed through as-is for
// backwards compatibility. If true, the user must be authenticated and the email must be
// in their verified list. "yes"/"true"/"1" resolves to the first verified email.
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
if !s.config.SMTPSenderVerify {
if toBool(email) {
return "", errHTTPBadRequestEmailAddressInvalid
}
return email, nil
} else if u == nil {
return "", errHTTPBadRequestAnonymousEmailNotAllowed
} else if s.userManager == nil {
return email, nil
}
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if len(emails) == 0 {
return "", errHTTPBadRequestEmailAddressNotVerified
}
if toBool(email) {
return emails[0], nil
} else if util.Contains(emails, email) {
return email, nil
}
return "", errHTTPBadRequestEmailAddressNotVerified
}
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
func (s *Server) publishSyncEventAsync(v *visitor) {
go func() {
+6 -2
View File
@@ -9,6 +9,7 @@ import (
"heckel.io/ntfy/v2/util"
"io"
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
@@ -673,7 +674,6 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
t.Parallel()
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.AuthDefault = user.PermissionReadWrite
conf.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately
s := newTestServer(t, conf)
// Create user with tier
@@ -741,7 +741,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
require.Equal(t, 200, rr.Code)
// Verify that messages and attachments were deleted
// This does not explicitly call the manager!
// This does not explicitly call the manager! We backdate the files so sync's
// grace period doesn't protect them.
past := time.Now().Add(-2 * time.Hour)
os.Chtimes(filepath.Join(s.config.AttachmentCacheDir, m1.ID), past, past)
os.Chtimes(filepath.Join(s.config.AttachmentCacheDir, m2.ID), past, past)
waitFor(t, func() bool {
s.attachment.Sync() // File cleanup is done by sync, not by the manager
ms, err := s.messageCache.Messages("mytopic1", model.SinceAllMessages, false)
+9
View File
@@ -103,6 +103,15 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
}
}
func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.mailSender == nil || s.userManager == nil {
return errHTTPNotFound
}
return next(w, r, v)
}
}
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.StripeSecretKey == "" || s.stripe == nil {
+6 -2
View File
@@ -14,6 +14,7 @@ import (
"heckel.io/ntfy/v2/util"
"io"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
@@ -443,7 +444,6 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
c := newTestConfigWithAuthFile(t, databaseURL)
c.StripeSecretKey = "secret key"
c.StripeWebhookKey = "webhook key"
c.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately
s := newTestServer(t, c)
s.stripe = stripeMock
@@ -544,7 +544,11 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Equal(t, 1, len(r)) // "ztopic" reservation was deleted
require.Equal(t, "atopic", r[0].Topic)
// Verify that messages and attachments were deleted
// Verify that messages and attachments were deleted. We backdate the
// attachment files so sync's grace period doesn't protect them.
past := time.Now().Add(-2 * time.Hour)
os.Chtimes(filepath.Join(s.config.AttachmentCacheDir, a2.ID), past, past)
os.Chtimes(filepath.Join(s.config.AttachmentCacheDir, z2.ID), past, past)
time.Sleep(time.Second)
s.execManager()
s.attachment.Sync() // File cleanup is done by sync, not by the manager
+177 -4
View File
@@ -1567,6 +1567,177 @@ func TestServer_PublishEmailAddressInvalid(t *testing.T) {
})
}
func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
// Verified address should succeed
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "phil@example.com",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
// Unverified address should fail
response = request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "other@example.com",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40052, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
// "yes" should resolve to first verified email
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
// "true" and "1" should also work
for _, val := range []string{"true", "1"} {
response = request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": val,
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code, "expected 200 for email: %s", val)
}
})
}
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
// "yes" without smtp-sender-verify should fail with invalid address
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
defer s.closeDatabases()
// Anonymous user should be rejected
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "test@example.com",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
// Authenticated user with no verified emails should fail
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "phil@example.com",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40052, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
// Without smtp-sender-verify, any email address should work (backwards compatible)
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "anyone@example.com",
})
require.Equal(t, 200, response.Code)
})
}
func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) {
// This test verifies that an authenticated user WITHOUT a tier can verify emails
// when the default visitor email limit allows it.
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
conf.SMTPSenderAddr = "localhost:25" // Dummy SMTP server (will fail to send, but that's ok)
conf.SMTPSenderFrom = "noreply@example.com"
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create a user without a tier
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Verify email request should NOT return 401
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
// The request will fail (SMTP not available), but it must NOT be a 401
require.NotEqual(t, 401, response.Code)
})
}
func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T) {
// This test verifies that a tier-less user is rejected when the server's
// visitor email limit is zero (email sending disabled).
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.VisitorEmailLimitBurst = 0
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create a user without a tier
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Should be rejected with 401 since email sending is disabled
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, response.Code)
})
}
func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
@@ -2285,7 +2456,6 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) {
c := newTestConfig(t, databaseURL)
c.AttachmentExpiryDuration = time.Millisecond // Hack
c.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately
s := newTestServer(t, c)
// Publish and make sure we can retrieve it
@@ -2300,7 +2470,9 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) {
require.Equal(t, 200, response.Code)
require.Equal(t, content, response.Body.String())
// Prune and makes sure it's gone
// Prune and makes sure it's gone. We backdate the file so sync's grace
// period doesn't protect it, then run the manager + sync explicitly.
require.Nil(t, os.Chtimes(file, time.Now().Add(-2*time.Hour), time.Now().Add(-2*time.Hour)))
waitFor(t, func() bool {
s.execManager()
s.attachment.Sync() // File cleanup is done by sync, not by the manager
@@ -2413,6 +2585,7 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test",
MessageLimit: 100,
MessageExpiryDuration: time.Hour,
AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000,
AttachmentExpiryDuration: 30 * time.Second,
@@ -2700,7 +2873,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
response := request(t, s, "PUT", "/mytopic", "some body", nil)
m := toMessage(t, response.Body.String())
require.Equal(t, "some body", m.Message)
require.True(t, time.Since(start) < 500*time.Millisecond)
require.True(t, time.Since(start) < 2*time.Second)
log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
// Wait for all Goroutines
@@ -4193,7 +4366,7 @@ func newTestConfigWithAuthFile(t *testing.T, databaseURL string) *Config {
func newTestServer(t *testing.T, config *Config) *Server {
server, err := New(config)
require.Nil(t, err)
t.Cleanup(server.closeDatabases)
t.Cleanup(server.Stop)
return server
}
+7 -16
View File
@@ -5,13 +5,12 @@ import (
"encoding/json"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
@@ -23,6 +22,7 @@ type mailer interface {
type smtpSender struct {
config *Config
sender *mail.Sender
success int64
failure int64
mu sync.Mutex
@@ -30,31 +30,22 @@ type smtpSender struct {
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
return s.withCount(v, m, func() error {
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m)
if err != nil {
return err
}
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.config.SMTPSenderFrom, to, m)
if err != nil {
return err
}
var auth smtp.Auth
if s.config.SMTPSenderUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
}
ev := logvm(v, m).
Tag(tagEmail).
Fields(log.Context{
"email_via": s.config.SMTPSenderAddr,
"email_user": s.config.SMTPSenderUser,
"email_via": s.sender.Addr(),
"email_user": s.sender.User(),
"email_to": to,
})
if ev.IsTrace() {
ev.Field("email_body", message).Trace("Sending email")
} else if ev.IsDebug() {
ev.Debug("Sending email")
}
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
ev.Info("Sending email")
return s.sender.SendRaw(to, []byte(message))
})
}
+11
View File
@@ -226,6 +226,15 @@ type apiAccountPhoneNumberAddRequest struct {
Code string `json:"code"` // Only set when adding a phone number
}
type apiAccountEmailVerifyRequest struct {
Email string `json:"email"`
}
type apiAccountEmailAddRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
type apiAccountTier struct {
Code string `json:"code"`
Name string `json:"name"`
@@ -282,6 +291,7 @@ type apiAccountResponse struct {
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []string `json:"phone_numbers,omitempty"`
Emails []string `json:"emails,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"`
@@ -302,6 +312,7 @@ type apiConfigResponse struct {
EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
EnableEmailVerify bool `json:"enable_email_verify"`
EnableReservations bool `json:"enable_reservations"`
EnableWebPush bool `json:"enable_web_push"`
BillingContact string `json:"billing_contact"`
+5 -1
View File
@@ -440,13 +440,17 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
if conf.VisitorMessageDailyLimit > 0 {
messagesLimit = int64(conf.VisitorMessageDailyLimit)
}
var emailLimit int64
if conf.VisitorEmailLimitBurst > 0 {
emailLimit = replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish) // Approximation!
}
return &visitorLimits{
Basis: visitorLimitBasisIP,
RequestLimitBurst: conf.VisitorRequestLimitBurst,
RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish),
MessageLimit: messagesLimit,
MessageExpiryDuration: conf.CacheDuration,
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
EmailLimit: emailLimit,
EmailLimitBurst: conf.VisitorEmailLimitBurst,
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
CallLimit: visitorDefaultCallsLimit,
+50
View File
@@ -1294,6 +1294,56 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
return phoneNumber, nil
}
// Emails returns all verified email addresses for the user with the given user ID
func (a *Manager) Emails(userID string) ([]string, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID)
if err != nil {
return nil, err
}
defer rows.Close()
emails := make([]string, 0)
for {
email, err := a.readEmail(rows)
if errors.Is(err, ErrEmailNotFound) {
break
} else if err != nil {
return nil, err
}
emails = append(emails, email)
}
return emails, nil
}
// AddEmail adds a verified email address to the user with the given user ID
func (a *Manager) AddEmail(userID, email string) error {
if _, err := a.db.Exec(a.queries.insertEmail, userID, email); err != nil {
if isUniqueConstraintError(err) {
return ErrEmailExists
}
return err
}
return nil
}
// RemoveEmail deletes a verified email address from the user with the given user ID
func (a *Manager) RemoveEmail(userID, email string) error {
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
return err
}
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
var email string
if !rows.Next() {
return "", ErrEmailNotFound
}
if err := rows.Scan(&email); err != nil {
return "", err
} else if err := rows.Err(); err != nil {
return "", err
}
return email, nil
}
// ChangeBilling updates a user's billing fields
func (a *Manager) ChangeBilling(username string, billing *Billing) error {
if _, err := a.db.Exec(a.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 {
+8
View File
@@ -208,6 +208,11 @@ const (
postgresInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)`
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
// Email queries
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
// Billing queries
postgresUpdateBillingQuery = `
UPDATE "user"
@@ -274,6 +279,9 @@ var postgresQueries = queries{
selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
insertPhoneNumber: postgresInsertPhoneNumberQuery,
deletePhoneNumber: postgresDeletePhoneNumberQuery,
selectEmails: postgresSelectEmailsQuery,
insertEmail: postgresInsertEmailQuery,
deleteEmail: postgresDeleteEmailQuery,
updateBilling: postgresUpdateBillingQuery,
}
+42 -3
View File
@@ -72,6 +72,11 @@ const (
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number)
);
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
email TEXT NOT NULL,
PRIMARY KEY (user_id, email)
);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
@@ -84,21 +89,55 @@ const (
// Schema table management queries for Postgres
const (
postgresCurrentSchemaVersion = 6
postgresCurrentSchemaVersion = 7
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
)
const (
postgresMigrate6To7UpdateQueries = `
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
email TEXT NOT NULL,
PRIMARY KEY (user_id, email)
);
`
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
)
var postgresMigrations = map[int]func(db *sql.DB) error{
6: postgresMigrateFrom6,
}
func setupPostgres(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewPostgres(db)
}
if schemaVersion > postgresCurrentSchemaVersion {
if schemaVersion == postgresCurrentSchemaVersion {
return nil
} else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
}
// Note: PostgreSQL migrations will be added when needed
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
fn, ok := postgresMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func postgresMigrateFrom6(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
}
+8
View File
@@ -207,6 +207,11 @@ const (
sqliteInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
// Email queries
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
// Billing queries
sqliteUpdateBillingQuery = `
UPDATE user
@@ -272,6 +277,9 @@ var sqliteQueries = queries{
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
insertPhoneNumber: sqliteInsertPhoneNumberQuery,
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
selectEmails: sqliteSelectEmailsQuery,
insertEmail: sqliteInsertEmailQuery,
deleteEmail: sqliteDeleteEmailQuery,
updateBilling: sqliteUpdateBillingQuery,
}
+31 -1
View File
@@ -85,6 +85,12 @@ const (
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL,
email TEXT NOT NULL,
PRIMARY KEY (user_id, email),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
@@ -101,7 +107,7 @@ const (
// Schema version table management for SQLite
const (
sqliteCurrentSchemaVersion = 6
sqliteCurrentSchemaVersion = 7
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@@ -220,6 +226,16 @@ const (
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
`
// 6 -> 7
sqliteMigrate6To7UpdateQueries = `
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL,
email TEXT NOT NULL,
PRIMARY KEY (user_id, email),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
`
// 5 -> 6
sqliteMigrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
@@ -322,6 +338,7 @@ var (
3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
6: sqliteMigrateFrom6,
}
)
@@ -463,3 +480,16 @@ func sqliteMigrateFrom5(sqlDB *sql.DB) error {
return nil
})
}
func sqliteMigrateFrom6(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 6 to 7")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
})
}
+75
View File
@@ -1137,6 +1137,60 @@ func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
})
}
func TestUser_EmailAddListRemove(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(phil.ID, "phil@example.com"))
emails, err := a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, 1, len(emails))
require.Equal(t, "phil@example.com", emails[0])
require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com"))
emails, err = a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, 0, len(emails))
// Paranoia check: We do NOT want to keep emails in there
rows, err := testDB(a).Query(`SELECT * FROM user_email`)
require.Nil(t, err)
require.False(t, rows.Next())
require.Nil(t, rows.Close())
})
}
func TestUser_EmailAdd_Multiple_Users_Same_Email(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
ben, err := a.User("ben")
require.Nil(t, err)
require.Nil(t, a.AddEmail(phil.ID, "shared@example.com"))
require.Nil(t, a.AddEmail(ben.ID, "shared@example.com"))
})
}
func TestUser_EmailAdd_Duplicate(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(phil.ID, "phil@example.com"))
require.ErrorIs(t, a.AddEmail(phil.ID, "phil@example.com"), ErrEmailExists)
})
}
func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
@@ -2328,6 +2382,27 @@ func TestStorePhoneNumbers(t *testing.T) {
})
}
func TestStoreEmails(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
u, err := manager.User("phil")
require.Nil(t, err)
require.Nil(t, manager.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, manager.AddEmail(u.ID, "phil2@example.com"))
emails, err := manager.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 2)
require.Nil(t, manager.RemoveEmail(u.ID, "phil@example.com"))
emails, err = manager.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 1)
require.Equal(t, "phil2@example.com", emails[0])
})
}
func TestStoreChangeSettings(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
+7
View File
@@ -271,6 +271,8 @@ var (
ErrPhoneNumberNotFound = errors.New("phone number not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
ErrPhoneNumberExists = errors.New("phone number already exists")
ErrEmailNotFound = errors.New("email not found")
ErrEmailExists = errors.New("email already exists")
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
)
@@ -343,6 +345,11 @@ type queries struct {
insertPhoneNumber string
deletePhoneNumber string
// Email queries
selectEmails string
insertEmail string
deleteEmail string
// Billing queries
updateBilling string
}
+170 -139
View File
@@ -2738,9 +2738,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
@@ -2752,9 +2752,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
@@ -2766,9 +2766,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
@@ -2780,9 +2780,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
@@ -2794,9 +2794,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
@@ -2808,9 +2808,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
@@ -2822,13 +2822,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2836,13 +2839,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2850,13 +2856,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2864,13 +2873,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2878,13 +2890,16 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2892,13 +2907,16 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2906,13 +2924,16 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2920,13 +2941,16 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2934,13 +2958,16 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2948,13 +2975,16 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2962,13 +2992,16 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2976,13 +3009,16 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2990,13 +3026,16 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3004,9 +3043,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
@@ -3018,9 +3057,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
@@ -3032,9 +3071,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
@@ -3046,9 +3085,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
@@ -3060,9 +3099,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
@@ -3074,9 +3113,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
@@ -3642,9 +3681,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.10",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz",
"integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==",
"version": "2.10.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz",
"integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3655,9 +3694,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3766,9 +3805,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001780",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==",
"version": "1.0.30001782",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz",
"integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==",
"dev": true,
"funding": [
{
@@ -4203,9 +4242,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
"version": "1.5.328",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz",
"integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==",
"dev": true,
"license": "ISC"
},
@@ -5005,9 +5044,9 @@
}
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6260,13 +6299,6 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true,
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -7068,9 +7100,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7580,9 +7612,9 @@
}
},
"node_modules/rollup": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7596,31 +7628,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.0",
"@rollup/rollup-android-arm64": "4.60.0",
"@rollup/rollup-darwin-arm64": "4.60.0",
"@rollup/rollup-darwin-x64": "4.60.0",
"@rollup/rollup-freebsd-arm64": "4.60.0",
"@rollup/rollup-freebsd-x64": "4.60.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
"@rollup/rollup-linux-arm64-musl": "4.60.0",
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
"@rollup/rollup-linux-loong64-musl": "4.60.0",
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
"@rollup/rollup-linux-x64-gnu": "4.60.0",
"@rollup/rollup-linux-x64-musl": "4.60.0",
"@rollup/rollup-openbsd-x64": "4.60.0",
"@rollup/rollup-openharmony-arm64": "4.60.0",
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
"@rollup/rollup-win32-x64-gnu": "4.60.0",
"@rollup/rollup-win32-x64-msvc": "4.60.0",
"@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
@@ -9094,14 +9126,13 @@
}
},
"node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz",
"integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==",
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
"integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-schema": "^0.4.0",
"jsonpointer": "^5.0.0",
"jsonpointer": "^5.0.1",
"leven": "^3.1.0"
},
"engines": {
@@ -9202,9 +9233,9 @@
}
},
"node_modules/workbox-build/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9270,9 +9301,9 @@
}
},
"node_modules/workbox-build/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
+14
View File
@@ -215,6 +215,19 @@
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Call",
"account_basics_emails_title": "Email addresses",
"account_basics_emails_description": "For email notifications",
"account_basics_emails_no_emails_yet": "No verified emails yet",
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
"account_basics_emails_dialog_title": "Add email address",
"account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.",
"account_basics_emails_dialog_email_label": "Email address",
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
"account_basics_emails_dialog_verify_button": "Add email",
"account_basics_emails_dialog_code_label": "Verification code",
"account_basics_emails_dialog_code_placeholder": "e.g. 123456",
"account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired",
"account_basics_emails_dialog_check_verification_button": "Confirm",
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
"account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}",
@@ -238,6 +251,7 @@
"account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent",
"account_usage_calls_title": "Phone calls made",
"account_usage_emails_none": "No email notifications can be sent with this account",
"account_usage_calls_none": "No phone calls can be made with this account",
"account_usage_reservations_title": "Reserved topics",
"account_usage_reservations_none": "No reserved topics for this account",
+39
View File
@@ -2,6 +2,8 @@ import i18n from "i18next";
import {
accountBillingPortalUrl,
accountBillingSubscriptionUrl,
accountEmailUrl,
accountEmailVerifyUrl,
accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
@@ -339,6 +341,43 @@ class AccountApi {
});
}
async verifyEmail(email) {
const url = accountEmailVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending email verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
}),
});
}
async addEmail(email, code) {
const url = accountEmailUrl(config.base_url);
console.log(`[AccountApi] Adding email with verification code ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
code,
}),
});
}
async deleteEmail(email) {
const url = accountEmailUrl(config.base_url);
console.log(`[AccountApi] Deleting email ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
}),
});
}
async sync() {
try {
if (!session.token()) {
+10
View File
@@ -47,6 +47,14 @@ export class IncorrectPasswordError extends Error {
}
}
export class EmailVerificationCodeInvalidError extends Error {
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
constructor() {
super("Email verification code invalid or expired");
}
}
export const throwAppError = async (response) => {
if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response);
@@ -63,6 +71,8 @@ export const throwAppError = async (response) => {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
throw new EmailVerificationCodeInvalidError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
+2
View File
@@ -34,6 +34,8 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
+199 -2
View File
@@ -53,7 +53,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import DialogFooter from "./DialogFooter";
import { Paragraph } from "./styles";
import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { ProChip } from "./SubscriptionPopup";
import session from "../app/Session";
@@ -84,6 +84,7 @@ const Basics = () => {
<PrefGroup>
<Username />
<ChangePassword />
<Emails />
<PhoneNumbers />
<AccountType />
</PrefGroup>
@@ -354,6 +355,200 @@ const AccountType = () => {
);
};
const Emails = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const labelId = "prefVerifiedEmails";
const handleDialogOpen = () => {
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
};
const handleDialogClose = () => {
setDialogOpen(false);
};
const handleCopy = (email) => {
copyToClipboard(email);
setSnackOpen(true);
};
const handleDelete = async (email) => {
try {
await accountApi.deleteEmail(email);
} catch (e) {
console.log(`[Account] Error deleting email`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
}
}
};
if (!config.enable_email_verify) {
return null;
}
if (account?.limits.emails === 0) {
return (
<Pref
title={
<>
{t("account_basics_emails_title")}
{config.enable_payments && <ProChip />}
</>
}
description={t("account_basics_emails_description")}
>
<em>{t("account_usage_emails_none")}</em>
</Pref>
);
}
return (
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
<div aria-labelledby={labelId}>
{account?.emails?.map((email) => (
<Chip
key={email}
label={
<Tooltip title={t("common_copy_to_clipboard")}>
<span>{email}</span>
</Tooltip>
}
variant="outlined"
onClick={() => handleCopy(email)}
onDelete={() => handleDelete(email)}
/>
))}
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
<IconButton onClick={handleDialogOpen}>
<AddIcon />
</IconButton>
</div>
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("account_basics_emails_copied_to_clipboard")}
/>
</Portal>
</Pref>
);
};
const AddEmailDialog = (props) => {
const theme = useTheme();
const { t } = useTranslation();
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [sending, setSending] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const verifyEmail = async () => {
try {
setSending(true);
await accountApi.verifyEmail(email);
setVerificationCodeSent(true);
} catch (e) {
console.log(`[Account] Error sending email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const checkVerifyEmail = async () => {
try {
setSending(true);
await accountApi.addEmail(email, code);
props.onClose();
} catch (e) {
console.log(`[Account] Error confirming email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else if (e instanceof EmailVerificationCodeInvalidError) {
setError(t("account_basics_emails_dialog_code_invalid"));
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const handleDialogSubmit = async () => {
if (!verificationCodeSent) {
await verifyEmail();
} else {
await checkVerifyEmail();
}
};
const handleCancel = () => {
if (verificationCodeSent) {
setVerificationCodeSent(false);
setCode("");
} else {
props.onClose();
}
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
{!verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_email_label")}
aria-label={t("account_basics_emails_dialog_email_label")}
placeholder={t("account_basics_emails_dialog_email_placeholder")}
type="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
fullWidth
variant="standard"
/>
)}
{verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_code_label")}
aria-label={t("account_basics_emails_dialog_code_label")}
placeholder={t("account_basics_emails_dialog_code_placeholder")}
type="text"
value={code}
onChange={(ev) => setCode(ev.target.value)}
fullWidth
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
variant="standard"
/>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
</Button>
</DialogFooter>
</Dialog>
);
};
const PhoneNumbers = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
@@ -750,7 +945,9 @@ const Stats = () => {
)}
</PrefGroup>
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
<Typography variant="body1">{t("account_usage_basis_ip_description")}</Typography>
<Typography variant="body1" sx={{ pt: 3 }}>
{t("account_usage_basis_ip_description")}
</Typography>
)}
</Card>
);
+2 -1
View File
@@ -117,7 +117,8 @@ const NavList = (props) => {
const isAdmin = account?.role === Role.ADMIN;
const isPaid = account?.billing?.subscription;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
const hasTier = !!account?.tier;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid && !hasTier;
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested());
const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());