mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-03-30 12:10:00 +02:00
Compare commits
9 Commits
v2.20.0
...
email-veri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61dd788dac | ||
|
|
266d0b9d37 | ||
|
|
835d1faac4 | ||
|
|
67fc7fe96a | ||
|
|
92fa88cf12 | ||
|
|
b5fee121d7 | ||
|
|
e1a344339f | ||
|
|
ae1ecfa1e9 | ||
|
|
874bdcf9f1 |
@@ -45,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 {
|
||||
|
||||
@@ -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-email-verify", Aliases: []string{"smtp_sender_email_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_EMAIL_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")
|
||||
smtpSenderEmailVerify := c.Bool("smtp-sender-email-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 smtpSenderEmailVerify && smtpSenderAddr == "" {
|
||||
return errors.New("if smtp-sender-email-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.SMTPSenderEmailVerify = smtpSenderEmailVerify
|
||||
conf.SMTPServerListen = smtpServerListen
|
||||
conf.SMTPServerDomain = smtpServerDomain
|
||||
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
||||
|
||||
@@ -353,6 +353,13 @@ 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>
|
||||
<select data-key="smtp-sender-email-verify">
|
||||
<option value="">No (default)</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cg-email-in-section" class="cg-hidden">
|
||||
<div class="cg-field"><label><strong>Incoming (publishing)</strong></label></div>
|
||||
@@ -538,7 +545,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 +553,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 +571,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.
|
||||
@@ -1028,7 +1038,21 @@ configured for `ntfy.sh`):
|
||||
smtp-sender-from: "ntfy@ntfy.sh"
|
||||
```
|
||||
|
||||
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-email-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.
|
||||
|
||||
=== "/etc/ntfy/server.yml (with email verification)"
|
||||
``` yaml
|
||||
smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587"
|
||||
smtp-sender-user: "AKIDEADBEEFAFFE12345"
|
||||
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
|
||||
smtp-sender-from: "ntfy@ntfy.sh"
|
||||
smtp-sender-email-verify: true
|
||||
```
|
||||
|
||||
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 +2213,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 +2221,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-email-verify` | `NTFY_SMTP_SENDER_EMAIL_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 +2316,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]
|
||||
|
||||
@@ -34,37 +34,37 @@ as a service starting at boot time.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.20.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.20.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.20.1_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.20.1_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.20.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.20.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.20.1_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.20.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.20.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.20.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.20.1_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.20.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.20.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.20.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.20.1_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.20.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.20.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.20.1_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.20.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.20.1_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.20.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.20.1_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.20.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.20.1_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
@@ -118,25 +118,25 @@ Install the ntfy server service script:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.20.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.20.1_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.20.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.20.1_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.20.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.20.1_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.20.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.20.1_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
@@ -204,7 +204,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -212,7 +212,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -220,7 +220,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -228,7 +228,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -238,28 +238,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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.20.0/ntfy_2.20.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -301,18 +301,18 @@ pkg install go-ntfy
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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.20.0/ntfy_2.20.0_darwin_all.tar.gz > ntfy_2.20.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.20.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.20.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_darwin_all.tar.gz > ntfy_2.20.1_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.20.1_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.20.1_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.20.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.20.1_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -333,7 +333,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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`
|
||||
|
||||
|
||||
@@ -6,13 +6,23 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.20.0 | Mar 26, 2026 |
|
||||
| ntfy server | v2.20.1 | Mar 27, 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.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)
|
||||
@@ -100,6 +110,13 @@ if things are working (or not working). There is a [one-off migration tool](http
|
||||
|
||||
* Preserve `<br>` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting)
|
||||
|
||||
## ntfy Android v1.25.x (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* 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)
|
||||
|
||||
## ntfy Android v1.24.0
|
||||
Released March 5, 2026
|
||||
|
||||
|
||||
1
docs/static/js/config-generator.js
vendored
1
docs/static/js/config-generator.js
vendored
@@ -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-email-verify", env: "NTFY_SMTP_SENDER_EMAIL_VERIFY", section: "smtp-out" },
|
||||
{ key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" },
|
||||
{ key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" },
|
||||
{ key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" },
|
||||
|
||||
2
go.mod
2
go.mod
@@ -44,7 +44,7 @@ require (
|
||||
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
|
||||
|
||||
4
go.sum
4
go.sum
@@ -10,8 +10,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
|
||||
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=
|
||||
|
||||
126
mail/mail.go
Normal file
126
mail/mail.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
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
|
||||
verifyCodes map[string]verifyCode // keyed by email
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type verifyCode struct {
|
||||
code string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// NewSender creates a new mail Sender with the given SMTP config
|
||||
func NewSender(config *Config) *Sender {
|
||||
return &Sender{
|
||||
config: config,
|
||||
verifyCodes: make(map[string]verifyCode),
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends a plain text email via SMTP
|
||||
func (s *Sender) Send(to, subject, body string) 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)
|
||||
}
|
||||
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 smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message))
|
||||
}
|
||||
|
||||
// SendVerification generates a 6-digit code, stores it in-memory, and sends a verification email
|
||||
func (s *Sender) SendVerification(to string) error {
|
||||
code, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.verifyCodes[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.verifyCodes[email]
|
||||
if !ok || time.Now().After(vc.expires) || vc.code != code {
|
||||
return false
|
||||
}
|
||||
delete(s.verifyCodes, email)
|
||||
return true
|
||||
}
|
||||
|
||||
// ExpireVerificationCodes removes expired entries from the in-memory map
|
||||
func (s *Sender) ExpireVerificationCodes() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
for email, vc := range s.verifyCodes {
|
||||
if now.After(vc.expires) {
|
||||
delete(s.verifyCodes, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateCode() (string, error) {
|
||||
max := big.NewInt(1000000) // 0-999999
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%06d", n.Int64()), nil
|
||||
}
|
||||
24
s3/client.go
24
s3/client.go
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
17
s3/types.go
17
s3/types.go
@@ -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.
|
||||
|
||||
21
s3/util.go
21
s3/util.go
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ type Config struct {
|
||||
SMTPSenderUser string
|
||||
SMTPSenderPass string
|
||||
SMTPSenderFrom string
|
||||
SMTPSenderEmailVerify bool
|
||||
SMTPServerListen string
|
||||
SMTPServerDomain string
|
||||
SMTPServerAddrPrefix string
|
||||
@@ -239,6 +240,7 @@ func NewConfig() *Config {
|
||||
SMTPSenderUser: "",
|
||||
SMTPSenderPass: "",
|
||||
SMTPSenderFrom: "",
|
||||
SMTPSenderEmailVerify: false,
|
||||
SMTPServerListen: "",
|
||||
SMTPServerDomain: "",
|
||||
SMTPServerAddrPrefix: "",
|
||||
|
||||
@@ -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, or no matching verified email addresses found", "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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
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,
|
||||
@@ -594,6 +606,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 {
|
||||
@@ -865,9 +883,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 != "" {
|
||||
} else if email != "" {
|
||||
if !vrate.EmailAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
||||
}
|
||||
var httpErr *errHTTP
|
||||
email, httpErr = s.convertEmailAddress(v.User(), email)
|
||||
if httpErr != nil {
|
||||
return nil, httpErr.With(t)
|
||||
}
|
||||
}
|
||||
if call != "" {
|
||||
var httpErr *errHTTP
|
||||
call, httpErr = s.convertPhoneNumber(v.User(), call)
|
||||
if httpErr != nil {
|
||||
|
||||
@@ -199,6 +199,13 @@
|
||||
# smtp-sender-user:
|
||||
# smtp-sender-pass:
|
||||
|
||||
# If set to true, only verified email recipients will receive email notifications.
|
||||
# Anonymous users will not be able to send emails, and authenticated users must verify
|
||||
# their email addresses first. Users can use "yes"/"true"/"1" as the email value to
|
||||
# send to their first verified address.
|
||||
#
|
||||
# smtp-sender-email-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.
|
||||
#
|
||||
|
||||
@@ -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,99 @@ 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 || (u.IsUser() && u.Tier == nil) {
|
||||
return errHTTPUnauthorized
|
||||
} else if u.IsUser() && u.Tier.EmailLimit == 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
|
||||
}
|
||||
// Send verification email
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("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
|
||||
}
|
||||
if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if !s.mailSender.CheckVerification(req.Email, req.Code) {
|
||||
return errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("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-email-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.SMTPSenderEmailVerify {
|
||||
return email, nil
|
||||
}
|
||||
if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||
}
|
||||
if s.userManager == nil {
|
||||
return email, nil
|
||||
}
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -15,6 +15,9 @@ func (s *Server) execManager() {
|
||||
s.pruneAttachments()
|
||||
s.pruneMessages()
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
if s.mailSender != nil {
|
||||
s.mailSender.ExpireVerificationCodes()
|
||||
}
|
||||
|
||||
// Message count
|
||||
messagesCached, err := s.messageCache.MessagesCount()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = ?`
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
12
web/package-lock.json
generated
12
web/package-lock.json
generated
@@ -3681,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.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz",
|
||||
"integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -4242,9 +4242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.325",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
|
||||
"integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==",
|
||||
"version": "1.5.326",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.326.tgz",
|
||||
"integrity": "sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
||||
@@ -215,6 +215,18 @@
|
||||
"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": "Verified email recipients",
|
||||
"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_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}}",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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?:\/\/.+/);
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ const Basics = () => {
|
||||
<PrefGroup>
|
||||
<Username />
|
||||
<ChangePassword />
|
||||
<VerifiedEmails />
|
||||
<PhoneNumbers />
|
||||
<AccountType />
|
||||
</PrefGroup>
|
||||
@@ -354,6 +355,198 @@ const AccountType = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const VerifiedEmails = () => {
|
||||
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_emails) {
|
||||
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 {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user