mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-09 21:35:20 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
469d263a5c | ||
|
|
432da44dc4 | ||
|
|
703d7bb9de | ||
|
|
98a0daba86 | ||
|
|
ce01b357d8 | ||
|
|
c40a2ce0a7 | ||
|
|
88e598d8b8 | ||
|
|
6869d166ae | ||
|
|
08d81a3645 | ||
|
|
202d858826 | ||
|
|
73e9d46b49 | ||
|
|
9a021aba2d | ||
|
|
812dc4cded | ||
|
|
9f6c4743b3 | ||
|
|
bde864756e | ||
|
|
b6d21415bb | ||
|
|
2e3d5babc8 | ||
|
|
b16efd2cb6 | ||
|
|
24f991c6d7 | ||
|
|
6ed57ec064 |
@@ -8,9 +8,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout ntfy code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: binwiederhier/ntfy-docs.github.io
|
||||
path: build/ntfy-docs.github.io
|
||||
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"text/template"
|
||||
@@ -98,6 +99,10 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before a visitor is banned"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
|
||||
@@ -215,6 +220,10 @@ func execServe(c *cli.Context) error {
|
||||
visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish")
|
||||
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
|
||||
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
|
||||
banFile := c.String("ban-file")
|
||||
banWindowStr := c.String("ban-window")
|
||||
banThreshold := c.Int("ban-threshold")
|
||||
banWeightsRaw := c.StringSlice("ban-weights")
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
||||
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
|
||||
@@ -270,6 +279,16 @@ func execServe(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr)
|
||||
}
|
||||
banWindow, err := util.ParseDuration(banWindowStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ban window: %s", banWindowStr)
|
||||
}
|
||||
|
||||
// Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback)
|
||||
banWeights, err := server.ParseBanWeights(banWeightsRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert sizes to bytes
|
||||
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
|
||||
@@ -372,6 +391,14 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
||||
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
||||
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
|
||||
} else if banFile != "" && banWindow <= 0 {
|
||||
return errors.New("if ban-file is set, ban-window must be greater than zero")
|
||||
} else if banFile != "" && banThreshold <= 0 {
|
||||
return errors.New("if ban-file is set, ban-threshold must be greater than zero")
|
||||
} else if banFile != "" && len(banWeights) == 0 {
|
||||
return errors.New("if ban-file is set, ban-weights must not be empty")
|
||||
} else if banFile != "" && !util.FileExists(filepath.Dir(banFile)) {
|
||||
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
|
||||
} else if runtime.GOOS == "windows" && listenUnix != "" {
|
||||
return errors.New("listen-unix is not supported on Windows")
|
||||
}
|
||||
@@ -512,6 +539,10 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish
|
||||
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
|
||||
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = banWindow
|
||||
conf.BanThreshold = banThreshold
|
||||
conf.BanWeights = banWeights
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.ProxyForwardedHeader = proxyForwardedHeader
|
||||
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
|
||||
|
||||
+38
-38
@@ -34,37 +34,37 @@ as a service starting at boot time.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
@@ -118,25 +118,25 @@ Install the ntfy server service script:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
@@ -204,7 +204,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -212,7 +212,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -220,7 +220,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -228,7 +228,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -238,28 +238,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.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.25.0/ntfy_2.25.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.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.25.0/ntfy_2.25.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.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.25.0/ntfy_2.25.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -301,18 +301,18 @@ pkg install go-ntfy
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.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.25.0/ntfy_2.25.0_darwin_all.tar.gz > ntfy_2.25.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.25.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz > ntfy_2.26.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.26.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.25.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.26.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -333,7 +333,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.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`
|
||||
|
||||
|
||||
+34
-23
@@ -6,12 +6,36 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|---------------|
|
||||
| ntfy server | v2.25.0 | June 24, 2026 |
|
||||
| ntfy server | v2.26.0 | July 9, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
### ntfy server v2.26.0
|
||||
Released July 9, 2026
|
||||
|
||||
This release hardens **message templates**, which are now executed with a hard-capped execution timeout. This closes
|
||||
a denial-of-service hole.
|
||||
|
||||
On the web app side, it adds configurable **date and time formats**, a smoother loading and page-transition experience,
|
||||
and a fix that strips unsafe URL protocols from rendered Markdown.
|
||||
|
||||
**Security:**
|
||||
|
||||
* Prevent a CPU denial of service via message templates (`Template: yes`) ([GHSA-rhwf-xgc9-m9fp](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-rhwf-xgc9-m9fp), [#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881) for reporting)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Web app: Add "Date format" and "Time format" settings (Settings -> Appearance), with ISO 8601, day/month/year (slash or dot) and month/day/year date options and a 12-/24-hour clock option, and base the default format on your browser/system locale rather than the selected display language. When logged in, both settings sync across devices via your account ([#1647](https://github.com/binwiederhier/ntfy/issues/1647), thanks to [@wsw70](https://github.com/wsw70) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Web app: Smooth transitions and loading animation, remove flickering
|
||||
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
||||
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
|
||||
* Web app: Strip unsafe URL protocols (`javascript:`, `data:`, ...) from links and images in Markdown-rendered messages, so they no longer trigger an uncaught "React has blocked a javascript: URL" error (thanks to [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||
|
||||
## ntfy server v2.25.0
|
||||
Released June 24, 2026
|
||||
|
||||
@@ -37,7 +61,6 @@ since I do have to reset accounts on a regular basis.
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Prevent a CPU denial of service via message templates (`Template: yes`) ([GHSA-rhwf-xgc9-m9fp](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-rhwf-xgc9-m9fp))
|
||||
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
||||
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
|
||||
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
|
||||
@@ -1984,34 +2007,20 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.26.x (UNRELEASED)
|
||||
### ntfy server v2.26.1 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Web app: Add "Date format" and "Time format" settings (Settings -> Appearance), with ISO 8601, day/month/year (slash or dot) and month/day/year date options and a 12-/24-hour clock option, and base the default format on your browser/system locale rather than the selected display language. When logged in, both settings sync across devices via your account ([#1647](https://github.com/binwiederhier/ntfy/issues/1647), thanks to [@wsw70](https://github.com/wsw70) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Web app: Smooth transitions and loading animation, remove flickering
|
||||
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
||||
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
|
||||
* Web app: Strip unsafe URL protocols (`javascript:`, `data:`, ...) from links and images in Markdown-rendered messages, so they no longer trigger an uncaught "React has blocked a javascript: URL" error (thanks to [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||
* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (see `ban-file`, `ban-window`, `ban-threshold`, `ban-weights`)
|
||||
|
||||
### ntfy Android v1.25.1 (UNRELEASED)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix instant delivery not resuming after the network returns on Android 12+: the app now keeps the foreground service alive and shows a "Waiting for network" state while offline, instead of stopping the service and failing to restart it ([#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
|
||||
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
|
||||
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
|
||||
|
||||
### ntfy Android v1.25.x (UNRELEASED)
|
||||
|
||||
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
||||
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
|
||||
|
||||
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
|
||||
is no network, ntfy will now stop the foreground service entirely.
|
||||
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
|
||||
once connectivity returns.
|
||||
|
||||
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
|
||||
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
|
||||
@@ -2021,16 +2030,18 @@ especially when paired with increaseing the server-side `keepalive-interval` in
|
||||
|
||||
**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)
|
||||
* Restart the foreground service immediately when network returns, even if the app process was killed while offline
|
||||
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
|
||||
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
|
||||
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
|
||||
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
|
||||
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
|
||||
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
|
||||
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
|
||||
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
### ntfy iOS app v1.8.0 (UNRELEASED)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/text v0.38.0
|
||||
golang.org/x/text v0.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -260,8 +260,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
+105
-3
@@ -7,6 +7,8 @@ import (
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -42,6 +44,18 @@ const (
|
||||
DefaultWebPushExpiryDuration = 60 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights)
|
||||
const (
|
||||
DefaultBanWindow = time.Minute
|
||||
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a visitor is banned
|
||||
)
|
||||
|
||||
// DefaultBanWeights defines the per-code strike weights used when the ban-feed is enabled but no
|
||||
// explicit ban-weights are configured. Format is "KEY:WEIGHT" (see ParseBanWeights). The auth-failure
|
||||
// flood is weighted heavily so it bans fast, the legit quota 429s are exempt (weight 0), and every
|
||||
// other rejection costs one strike via the "*" fallback.
|
||||
var DefaultBanWeights = []string{"42909:10", "42908:0", "42903:0", "42905:0", "42910:0", "*:1"}
|
||||
|
||||
// Defines all global and per-visitor limits
|
||||
// - message size limit: the max number of bytes for a message
|
||||
// - total topic limit: max number of topics overall
|
||||
@@ -197,9 +211,13 @@ type Config struct {
|
||||
WebPushStartupQueries string
|
||||
WebPushExpiryDuration time.Duration
|
||||
WebPushExpiryWarningDuration time.Duration
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
|
||||
BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
|
||||
BanThreshold int // Abuse ban-feed: weighted strikes per window before a visitor is banned
|
||||
BanWeights map[string]int // Abuse ban-feed: normalized code matcher -> strike weight (see ParseBanWeights, weightFor)
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
@@ -300,6 +318,10 @@ func NewConfig() *Config {
|
||||
WebPushEmailAddress: "",
|
||||
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||
BanFile: "",
|
||||
BanWindow: DefaultBanWindow,
|
||||
BanThreshold: DefaultBanThreshold,
|
||||
BanWeights: nil,
|
||||
BuildVersion: "",
|
||||
BuildDate: "",
|
||||
BuildCommit: "",
|
||||
@@ -323,3 +345,83 @@ func (c *Config) Hash() string {
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
|
||||
}
|
||||
|
||||
// ParseBanWeights turns a list like ["42909:10","403:2","42908:0","*:1"] into a map of normalized
|
||||
// matcher key -> strike weight for the abuse ban-feed's single weighted bucket. A key may be an exact
|
||||
// ntfy code ("42909"), a prefix family ("429*"), or "*". A bare 3-digit HTTP status ("403") is a
|
||||
// shorthand normalized to its family ("403*"), so operators can weight a whole status at once. Weights
|
||||
// must be integers >= 0; a weight of 0 is valid and means the code is exempt (never contributes to a
|
||||
// ban), which lets a "*" catch-all coexist with carved-out legit-quota codes. A malformed entry is
|
||||
// rejected so misconfiguration surfaces at startup rather than silently disabling bans.
|
||||
func ParseBanWeights(entries []string) (map[string]int, error) {
|
||||
out := make(map[string]int, len(entries))
|
||||
for _, entry := range entries {
|
||||
key, weightStr, ok := strings.Cut(entry, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
|
||||
}
|
||||
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
|
||||
if err != nil || weight < 0 {
|
||||
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if !validBanWeightKey(key) {
|
||||
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
|
||||
}
|
||||
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
|
||||
if len(key) == 3 && isAllDigits(key) {
|
||||
key += "*"
|
||||
}
|
||||
out[key] = weight
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validBanWeightKey reports whether key is a legal ban-weight matcher: "*", an all-digits code,
|
||||
// or an all-digits prefix followed by "*".
|
||||
func validBanWeightKey(key string) bool {
|
||||
if key == "*" {
|
||||
return true
|
||||
}
|
||||
digits := strings.TrimSuffix(key, "*")
|
||||
return digits != "" && isAllDigits(digits)
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// weightFor returns the strike weight for a rejection's 5-digit ntfy code using the ban-weights
|
||||
// matcher, longest-match-wins. An exact key ("42909") matches with length len(code); a family key
|
||||
// ("429*") matches a code with that prefix, with length equal to the prefix; "*" matches everything
|
||||
// with length 0. The weight of the longest match is returned, or 0 if no key matches (which the
|
||||
// caller treats as "no strike").
|
||||
func (c *Config) weightFor(ntfyCode int) int {
|
||||
code := strconv.Itoa(ntfyCode)
|
||||
weight, bestLen, matched := 0, -1, false
|
||||
for key, w := range c.BanWeights {
|
||||
matchLen := -1
|
||||
switch {
|
||||
case key == "*":
|
||||
matchLen = 0
|
||||
case strings.HasSuffix(key, "*"):
|
||||
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
|
||||
matchLen = len(prefix)
|
||||
}
|
||||
case key == code:
|
||||
matchLen = len(code)
|
||||
}
|
||||
if matchLen > bestLen {
|
||||
weight, bestLen, matched = w, matchLen, true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return 0
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
+10
-1
@@ -472,7 +472,7 @@ func (s *Server) closeDatabases() {
|
||||
|
||||
// handle is the main entry point for all HTTP requests
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
v, err := s.maybeAuthenticate(r) // Note: Always returns v, even when error is returned
|
||||
r, v, err := s.maybeAuthenticate(r) // Note: Always returns v (and r, with the client IP in its context), even on error
|
||||
if err != nil {
|
||||
s.handleError(w, r, v, err)
|
||||
return
|
||||
@@ -538,6 +538,15 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
io.WriteString(w, httpErr.JSON()+"\n")
|
||||
if s.config.BanFile != "" {
|
||||
// Abuse ban-feed: record against the OFFENDING request's IP, not v.ip. An account-keyed
|
||||
// (tier'd) visitor is one object shared across all its source IPs, so v.ip is stale. The IP was
|
||||
// already extracted in maybeAuthenticate and stashed in the request context (contextVisitorIP),
|
||||
// so reuse it here rather than re-parsing headers.
|
||||
if ip, err := fromContext[netip.Addr](r, contextVisitorIP); err == nil {
|
||||
v.recordStatus(ip, httpErr.HTTPCode, httpErr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
|
||||
@@ -370,6 +370,39 @@
|
||||
# visitor-topic-creation-limit-burst: 100
|
||||
# visitor-topic-creation-limit-replenish: "1m"
|
||||
|
||||
# Abuse ban-feed: Count HTTP response statuses per visitor and append abusive IPs to a file that
|
||||
# fail2ban (or similar) can tail and ban on sight. This captures ntfy-layer rejections (e.g. ACL
|
||||
# 403s and ntfy's own 429s), so fail2ban does not have to regex-parse the full access log.
|
||||
# - ban-file is the file abusive IPs are appended to; leave empty to disable the feature. Its
|
||||
# directory must exist and be writable by ntfy. Rotate it (e.g. logrotate, copytruncate) so it
|
||||
# cannot grow unbounded.
|
||||
# - ban-window is the rolling window over which weighted strikes are counted, per visitor.
|
||||
# - ban-threshold is the number of weighted strikes per window before a visitor is banned. Each
|
||||
# visitor has ONE strike budget; rejections draw it down, so there is no way to game it by mixing
|
||||
# codes.
|
||||
# - ban-weights assigns a strike weight to a matcher KEY (KEY:WEIGHT). A KEY is an exact ntfy code
|
||||
# ("42909"), a prefix family ("429*"), a bare HTTP status ("403", shorthand for "403*"), or "*".
|
||||
# Longest match wins. A weight of 0 exempts a code (never contributes to a ban), so the legit quota
|
||||
# 429s can be carved out from a "*" catch-all. Heavier weights ban faster (auth-failure floods).
|
||||
#
|
||||
# Each appended line has the exact format
|
||||
# "<RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>", for example:
|
||||
# 2026-07-17T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901
|
||||
# 2026-07-17T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909
|
||||
# <prefix> is <ip> masked to the rate-limiting prefix (visitor-prefix-bits-ipv4/ipv6); that is the
|
||||
# unit a fail2ban jail should ban, so a whole IPv6 subnet is banned as one.
|
||||
#
|
||||
# ban-file: "/var/log/ntfy-ban.log"
|
||||
# ban-window: "1m"
|
||||
# ban-threshold: 100
|
||||
# ban-weights:
|
||||
# - "42909:10" # auth-failure flood: bans in ~10
|
||||
# - "42908:0" # daily message quota reached -> legit, never counts
|
||||
# - "42903:0" # subscription limit -> legit
|
||||
# - "42905:0" # daily bandwidth reached -> legit
|
||||
# - "42910:0" # daily phone call quota reached -> legit
|
||||
# - "*:1" # everything else 4xx/5xx
|
||||
|
||||
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
|
||||
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
|
||||
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
|
||||
|
||||
+11
-7
@@ -21,31 +21,35 @@ import (
|
||||
//
|
||||
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
|
||||
// that subsequent logging calls still have a visitor context.
|
||||
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
||||
func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, error) {
|
||||
// Read the "Authorization" header value and exit out early if it's not set
|
||||
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||
// Stash the extracted client IP in the request context so downstream code (the abuse ban-feed in
|
||||
// handleError) can reuse it without re-parsing headers, and so an account-keyed (tier'd) visitor --
|
||||
// whose shared visitor object has a stale v.ip -- is still attributed to the actual request IP.
|
||||
r = withContext(r, map[contextKey]any{contextVisitorIP: ip})
|
||||
vip := s.visitor(ip, nil)
|
||||
if s.userManager == nil {
|
||||
return vip, nil
|
||||
return r, vip, nil
|
||||
}
|
||||
header, err := readAuthHeader(r)
|
||||
if err != nil {
|
||||
return vip, err
|
||||
return r, vip, err
|
||||
} else if !supportedAuthHeader(header) {
|
||||
return vip, nil
|
||||
return r, vip, nil
|
||||
}
|
||||
// If we're trying to auth, check the rate limiter first
|
||||
if !vip.AuthAllowed() {
|
||||
return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
|
||||
return r, vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
|
||||
}
|
||||
u, err := s.authenticate(r, header)
|
||||
if err != nil {
|
||||
vip.AuthFailed()
|
||||
logr(r).Err(err).Debug("Authentication failed")
|
||||
return vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
|
||||
return r, vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
|
||||
}
|
||||
// Authentication with user was successful
|
||||
return s.visitor(ip, u), nil
|
||||
return r, s.visitor(ip, u), nil
|
||||
}
|
||||
|
||||
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
contextRateVisitor contextKey = iota + 2586
|
||||
contextTopic
|
||||
contextMatrixPushKey
|
||||
contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see recordStatus)
|
||||
)
|
||||
|
||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
|
||||
+48
-7
@@ -2851,7 +2851,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "8.9.10.11", v.ip.String())
|
||||
})
|
||||
@@ -2865,7 +2865,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "1.1.1.1", v.ip.String())
|
||||
})
|
||||
@@ -2879,7 +2879,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "234.5.2.1", v.ip.String())
|
||||
})
|
||||
@@ -2894,7 +2894,7 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("X-Client-IP", "1.2.3.4")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "1.2.3.4", v.ip.String())
|
||||
})
|
||||
@@ -2909,7 +2909,7 @@ func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "[2001:db8:9999::1]:1234"
|
||||
r.Header.Set("X-Client-IP", "2001:db8:7777::1")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "2001:db8:7777::1", v.ip.String())
|
||||
})
|
||||
@@ -2925,7 +2925,7 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "5.6.7.8", v.ip.String())
|
||||
})
|
||||
@@ -2941,7 +2941,7 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "[2001:db8:2222::1]:1234"
|
||||
r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "2001:db8:3333::1", v.ip.String())
|
||||
})
|
||||
@@ -5147,3 +5147,44 @@ func TestServer_Publish_InvalidUTF8WithFirebase(t *testing.T) {
|
||||
require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"])
|
||||
require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"])
|
||||
}
|
||||
|
||||
func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) {
|
||||
// Real requests: exhaust the visitor request limit so ntfy returns 429s, and confirm the
|
||||
// client IP is written to the ban file after it breaches the per-status ban limit.
|
||||
banFile := filepath.Join(t.TempDir(), "ntfy-ban.log")
|
||||
c := newTestConfig(t, "")
|
||||
c.BanFile = banFile
|
||||
c.BanWindow = time.Minute
|
||||
c.BanThreshold = 2 // Ban after the weighted budget of 2 is exhausted
|
||||
c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike
|
||||
c.VisitorRequestLimitBurst = 2 // 429 quickly
|
||||
s := newTestServer(t, c)
|
||||
got429 := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
rr := request(t, s, "PUT", "/mytopic", "x", nil)
|
||||
if rr.Code == 429 {
|
||||
got429++
|
||||
}
|
||||
}
|
||||
require.Greater(t, got429, 2)
|
||||
data, err := os.ReadFile(banFile)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "9.9.9.9 9.9.9.9/32 429 42901") // <ip> <prefix> <http> <ntfy-code>
|
||||
}
|
||||
|
||||
func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) {
|
||||
// Real requests that all succeed (200) must never trigger a ban, even with a low "*" fallback.
|
||||
banFile := filepath.Join(t.TempDir(), "ntfy-ban.log")
|
||||
c := newTestConfig(t, "")
|
||||
c.BanFile = banFile
|
||||
c.BanWindow = time.Minute
|
||||
c.BanThreshold = 3 // Low threshold that would catch 200s if 2xx were not skipped
|
||||
c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike
|
||||
c.VisitorRequestLimitBurst = 100 // Stay under the request limit so every request is 200
|
||||
s := newTestServer(t, c)
|
||||
for i := 0; i < 10; i++ {
|
||||
rr := request(t, s, "PUT", "/mytopic", fmt.Sprintf("m%d", i), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
require.NoFileExists(t, banFile)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -70,6 +71,8 @@ type visitor struct {
|
||||
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||
banLimiter *rate.Limiter // Abuse ban-feed: single weighted breach detector (rate.Limiter is concurrency-safe), nil when feature disabled
|
||||
banEmit *rate.Limiter // Abuse ban-feed: throttles writes so a persistent offender only re-appears occasionally, nil when disabled
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -142,6 +145,13 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man
|
||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||
}
|
||||
// Abuse ban-feed: only wire up per-visitor tracking when the feature is enabled. One weighted
|
||||
// token bucket (capacity BanThreshold, refilled at BanThreshold/BanWindow per second) is the
|
||||
// breach detector; banEmit throttles writes so the feed stays tiny.
|
||||
if conf.BanFile != "" {
|
||||
v.banLimiter = rate.NewLimiter(rate.Limit(float64(conf.BanThreshold)/conf.BanWindow.Seconds()), conf.BanThreshold)
|
||||
v.banEmit = rate.NewLimiter(rate.Every(conf.BanWindow), 1)
|
||||
}
|
||||
v.resetLimitersNoLock(messages, emails, calls, false)
|
||||
return v
|
||||
}
|
||||
@@ -551,6 +561,73 @@ func dailyLimitToRate(limit int64) rate.Limit {
|
||||
return rate.Limit(limit) * rate.Every(oneDay)
|
||||
}
|
||||
|
||||
// recordStatus is called once per request with the offending request's IP and the final HTTP status
|
||||
// and ntfy code. Each rejection (4xx/5xx only) consumes weightFor(ntfyCode) tokens from the visitor's
|
||||
// single weighted ban bucket; a code weighted 0 (or one that matches no rule) is exempt and never
|
||||
// counts. When the bucket cannot cover a rejection the visitor has breached, and ip is appended to
|
||||
// the ban file that fail2ban tails (throttled via banEmit so a persistent offender only re-appears
|
||||
// occasionally). ip is passed in rather than read from v.ip because an account-keyed (tier'd) visitor
|
||||
// is shared across all its source IPs, so v.ip would be stale; we ban the address that breached.
|
||||
// This is on the hot path, so it no-ops immediately when the feature is disabled.
|
||||
func (v *visitor) recordStatus(ip netip.Addr, httpCode, ntfyCode int) {
|
||||
if v.config.BanFile == "" {
|
||||
return // Feature disabled
|
||||
}
|
||||
if httpCode < 400 {
|
||||
return // Only rejections (4xx/5xx) count toward a ban; success and redirects never do
|
||||
}
|
||||
// Note: tier'd (account-keyed) visitors are NOT exempt. A persistent 429 stream is abusive
|
||||
// regardless of the account behind it, and banning the offending IP is the right response --
|
||||
// exactly what the pre-existing nginx-side jails already did. The legit case (a paid user merely
|
||||
// hitting a plan/quota limit) is spared by the per-code weights instead: quota codes like 42908
|
||||
// are weighted 0 below. Only request-limiter floods (42901, weight 1) actually accrue strikes.
|
||||
weight := v.config.weightFor(ntfyCode)
|
||||
if weight <= 0 {
|
||||
return // Exempt (weight 0) or unmatched code: no strike
|
||||
}
|
||||
if v.banLimiter.AllowN(time.Now(), weight) {
|
||||
return // The bucket covered this rejection; still within the strike budget
|
||||
}
|
||||
// Breached. Throttle so the feed stays tiny even if the offender keeps hammering.
|
||||
if v.banEmit.Allow() {
|
||||
writeBanLine(v.config.BanFile, ip, visitorPrefix(ip, v.config), httpCode, ntfyCode)
|
||||
}
|
||||
}
|
||||
|
||||
// banFileMu serializes appends to the ban file across all visitors (multiple visitors may breach
|
||||
// concurrently). The feed is pre-filtered, so write volume is low and a single mutex is plenty.
|
||||
var banFileMu sync.Mutex
|
||||
|
||||
// writeBanLine appends a single line to the ban file:
|
||||
//
|
||||
// <RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>
|
||||
//
|
||||
// e.g. "2026-07-18T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901". <prefix> is <ip> masked to the configured
|
||||
// rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6); that is the unit fail2ban should ban, so an IPv6
|
||||
// client (which owns a whole /64) is banned as one, matching how ntfy rate-limits it. This exact
|
||||
// format is a contract that a fail2ban jail parses. Best-effort; any error is swallowed, because a
|
||||
// failure to write the ban feed must never fail the underlying request.
|
||||
func writeBanLine(path string, ip netip.Addr, prefix netip.Prefix, httpCode, ntfyCode int) {
|
||||
banFileMu.Lock()
|
||||
defer banFileMu.Unlock()
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Fprintf(f, "%s %s %s %d %d\n", time.Now().UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, ntfyCode)
|
||||
}
|
||||
|
||||
// visitorPrefix masks ip to the configured rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6), so the
|
||||
// ban feed reports the same unit ntfy rate-limits by -- e.g. a whole /64 for an IPv6 client -- rather
|
||||
// than a single address. fail2ban bans that prefix.
|
||||
func visitorPrefix(ip netip.Addr, conf *Config) netip.Prefix {
|
||||
if ip.Is4() {
|
||||
return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked()
|
||||
}
|
||||
return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked()
|
||||
}
|
||||
|
||||
// visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6
|
||||
func visitorID(ip netip.Addr, u *user.User, conf *Config) string {
|
||||
if u != nil && u.Tier != nil {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// newBanTestVisitor creates a visitor wired for ban-feed testing, with the given ban file,
|
||||
// weighted-bucket threshold, and weights, plus a 1-minute window (so the emit throttle only
|
||||
// fires once per test).
|
||||
func newBanTestVisitor(t *testing.T, banFile string, threshold int, weights map[string]int) *visitor {
|
||||
conf := NewConfig()
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = time.Minute
|
||||
conf.BanThreshold = threshold
|
||||
conf.BanWeights = weights
|
||||
return newVisitor(conf, nil, nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
}
|
||||
|
||||
func readBanLines(t *testing.T, path string) []string {
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_Weight2BansAtHalfThreshold(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
|
||||
v := newBanTestVisitor(t, banFile, 10, map[string]int{"*": 2})
|
||||
for i := 0; i < 5; i++ {
|
||||
v.recordStatus(v.ip, 400, 40001)
|
||||
}
|
||||
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
|
||||
v.recordStatus(v.ip, 400, 40001) // 6th hit cannot be covered -> breach
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_Weight10BansFast(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
|
||||
v := newBanTestVisitor(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
|
||||
v.recordStatus(v.ip, 429, 42909)
|
||||
require.NoFileExists(t, banFile)
|
||||
v.recordStatus(v.ip, 429, 42909) // 2nd hit cannot be covered -> breach
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_Weight0NeverBans(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
|
||||
v := newBanTestVisitor(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
|
||||
for i := 0; i < 100; i++ {
|
||||
v.recordStatus(v.ip, 429, 42908)
|
||||
}
|
||||
require.NoFileExists(t, banFile)
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_SingleBucketNoRelaxation(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// One shared bucket: different codes draw down the SAME budget, so mixing them creates no extra
|
||||
// headroom (unlike per-code buckets, which would relax the effective limit for a mixed offender).
|
||||
v := newBanTestVisitor(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
|
||||
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 8 left
|
||||
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 6 left
|
||||
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 4 left
|
||||
require.NoFileExists(t, banFile)
|
||||
for i := 0; i < 4; i++ {
|
||||
v.recordStatus(v.ip, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
|
||||
}
|
||||
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
|
||||
v.recordStatus(v.ip, 400, 40001) // one more cannot be covered -> breach
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_ExactLineFormat(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1})
|
||||
before := time.Now().UTC().Truncate(time.Second)
|
||||
for i := 0; i < 3; i++ {
|
||||
v.recordStatus(v.ip, 429, 42901)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
parts := strings.Split(lines[0], " ")
|
||||
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
|
||||
ts, err := time.Parse(time.RFC3339, parts[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.UTC, ts.Location())
|
||||
require.False(t, ts.Before(before))
|
||||
require.False(t, ts.After(after.Add(time.Second)))
|
||||
require.Equal(t, "1.2.3.4", parts[1]) // full IP
|
||||
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
|
||||
require.Equal(t, "429", parts[3]) // HTTP status
|
||||
require.Equal(t, "42901", parts[4]) // ntfy code
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_IPv6MaskedToPrefix(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
conf := NewConfig()
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = time.Minute
|
||||
conf.BanThreshold = 1
|
||||
conf.BanWeights = map[string]int{"*": 1}
|
||||
v := newVisitor(conf, nil, nil, netip.MustParseAddr("2001:db8::abcd"), nil)
|
||||
for i := 0; i < 3; i++ {
|
||||
v.recordStatus(v.ip, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(readBanLines(t, banFile)[0], " ")
|
||||
require.Len(t, parts, 5)
|
||||
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
|
||||
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_TierUserAlsoBanned(t *testing.T) {
|
||||
// A tier'd user who keeps hammering the request limiter (42901) is banned by IP like anyone
|
||||
// else -- a persistent 429 stream is abusive regardless of the account behind it. The legit
|
||||
// case (hitting a paid plan/quota limit) is spared by the per-code weights, not by a tier skip:
|
||||
// codes like 42908 are weight 0. So tier does not exempt a visitor from the ban feed.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1})
|
||||
v.user = &user.User{ID: "u_test", Name: "test", Tier: &user.Tier{ID: "ti_test"}}
|
||||
for i := 0; i < 10; i++ {
|
||||
v.recordStatus(v.ip, 429, 42901)
|
||||
}
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_BansOffendingIPNotVisitorIP(t *testing.T) {
|
||||
// For an account-keyed (tier'd) visitor, one visitor object serves many source IPs and its
|
||||
// stored v.ip is whichever IP created it first -- stale. The ban feed must write the IP of the
|
||||
// request that actually breached, passed in per-call, not the visitor's stored IP.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) // v.ip == 1.2.3.4
|
||||
offender := netip.MustParseAddr("5.6.7.8")
|
||||
for i := 0; i < 3; i++ {
|
||||
v.recordStatus(offender, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(readBanLines(t, banFile)[0], " ")
|
||||
require.Equal(t, "5.6.7.8", parts[1]) // the offending request IP, not v.ip (1.2.3.4)
|
||||
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix, not the visitor's stored-IP prefix
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_DisabledWhenNoBanFile(t *testing.T) {
|
||||
v := newBanTestVisitor(t, "", 10, map[string]int{"*": 1})
|
||||
for i := 0; i < 5; i++ {
|
||||
v.recordStatus(v.ip, 403, 40301) // Feature disabled: must be a no-op, must not panic
|
||||
}
|
||||
require.Nil(t, v.banEmit) // No throttle limiter is created when the feature is off
|
||||
}
|
||||
|
||||
func TestVisitor_RecordStatus_Ignores2xx3xx(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
v := newBanTestVisitor(t, banFile, 3, map[string]int{"*": 1})
|
||||
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
|
||||
// legit high-volume publisher (lots of 200s) would get banned.
|
||||
for i := 0; i < 20; i++ {
|
||||
v.recordStatus(v.ip, 200, 20000)
|
||||
v.recordStatus(v.ip, 302, 30000)
|
||||
}
|
||||
require.NoFileExists(t, banFile)
|
||||
// A 4xx over the same budget still gets written.
|
||||
for i := 0; i < 5; i++ {
|
||||
v.recordStatus(v.ip, 400, 40001)
|
||||
}
|
||||
lines := readBanLines(t, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
|
||||
}
|
||||
|
||||
func TestParseBanWeights(t *testing.T) {
|
||||
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
|
||||
weights, err := ParseBanWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]int{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
|
||||
|
||||
// A bare 3-digit HTTP status normalizes to its family.
|
||||
weights, err = ParseBanWeights([]string{"429:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]int{"429*": 5}, weights)
|
||||
|
||||
// An explicit family key stays as-is.
|
||||
weights, err = ParseBanWeights([]string{"429*:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]int{"429*": 5}, weights)
|
||||
|
||||
// Weight 0 is valid and means exempt.
|
||||
weights, err = ParseBanWeights([]string{"42908:0"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]int{"42908": 0}, weights)
|
||||
|
||||
_, err = ParseBanWeights([]string{"401"}) // Missing weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseBanWeights([]string{"401:-1"}) // Negative weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseBanWeights([]string{"401:abc"}) // Non-integer weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseBanWeights([]string{"abc:10"}) // Non-numeric key
|
||||
require.Error(t, err)
|
||||
_, err = ParseBanWeights([]string{"4*3:10"}) // Star not at the end
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConfig_WeightFor(t *testing.T) {
|
||||
conf := NewConfig()
|
||||
weights, err := ParseBanWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
|
||||
require.NoError(t, err)
|
||||
conf.BanWeights = weights
|
||||
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
|
||||
require.Equal(t, 0, conf.weightFor(42908))
|
||||
require.Equal(t, 10, conf.weightFor(42909))
|
||||
require.Equal(t, 1, conf.weightFor(42901))
|
||||
require.Equal(t, 2, conf.weightFor(40311))
|
||||
require.Equal(t, 1, conf.weightFor(40011))
|
||||
require.Equal(t, 1, conf.weightFor(50312))
|
||||
// A code that matches nothing (no "*" here) returns 0.
|
||||
require.Equal(t, 0, conf.weightFor(30012))
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
Generated
+11
-11
@@ -16,7 +16,7 @@
|
||||
"dexie": "^4.4.4",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^4.0.0",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
@@ -36,7 +36,7 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier": "^3.9.4",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
@@ -5694,12 +5694,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "6.1.8",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.8.tgz",
|
||||
"integrity": "sha512-Svm+MduCElO0Meqpj1kJAriTC6OhI41VhlT/A0UPjGoPZBhAHIaGE5EfsHlTpgdH09UVX7rcc72pSDDBeKSQQA==",
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.19.0"
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
@@ -7576,16 +7576,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "2.8.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
|
||||
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
|
||||
"version": "3.9.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz",
|
||||
"integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin-prettier.js"
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@
|
||||
"dexie": "^4.4.4",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^4.0.0",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
@@ -41,7 +41,7 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier": "^3.9.4",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2");
|
||||
src:
|
||||
local(""),
|
||||
url("../fonts/roboto-v29-latin-300.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* roboto-regular - latin */
|
||||
@@ -13,7 +15,9 @@
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2");
|
||||
src:
|
||||
local(""),
|
||||
url("../fonts/roboto-v29-latin-regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* roboto-500 - latin */
|
||||
@@ -21,7 +25,9 @@
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2");
|
||||
src:
|
||||
local(""),
|
||||
url("../fonts/roboto-v29-latin-500.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* roboto-700 - latin */
|
||||
@@ -29,5 +35,7 @@
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2");
|
||||
src:
|
||||
local(""),
|
||||
url("../fonts/roboto-v29-latin-700.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@@ -243,8 +243,8 @@
|
||||
"account_basics_password_description": "Промяна на паролата на профила",
|
||||
"account_basics_tier_title": "Вид на профила",
|
||||
"account_basics_tier_admin": "Администратор",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(с {{tier}} ниво)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(без ниво)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "с {{tier}} ниво",
|
||||
"account_basics_tier_admin_suffix_no_tier": "без ниво",
|
||||
"account_basics_tier_free": "безплатен",
|
||||
"account_basics_tier_basic": "базов",
|
||||
"account_basics_tier_change_button": "Променяне",
|
||||
@@ -373,7 +373,7 @@
|
||||
"prefs_reservations_table_everyone_read_write": "Всички могат да публикуват и да се абонират",
|
||||
"reservation_delete_dialog_submit_button": "Премахване на резервирането",
|
||||
"account_tokens_description": "Използвайте код за достъп когато публикувате или се абонирате през ППИ на ntfy, за да не се налага да изпращате потребителско име и парола. Прочетете <Link>документацията</Link> за повече информация.",
|
||||
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>",
|
||||
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>.",
|
||||
"prefs_reservations_dialog_description": "Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
|
||||
"reservation_delete_dialog_action_keep_title": "Пазене на съобщения и прикачени файлове",
|
||||
"reservation_delete_dialog_action_keep_description": "Съобщенията и прикачените файлове, които са във временната памет на сървъра ще бъдат достъпни за всеки, който знае името на темата.",
|
||||
@@ -418,5 +418,46 @@
|
||||
"version_update_available_description": "Сървърът на ntfy е обновен. Презаредете страницата.",
|
||||
"signup_form_email": "Адрес на ел. поща (по желание, с цел възстановяване)",
|
||||
"login_link_forgot_password": "Забравена парола",
|
||||
"reset_password_request_title": "Нулиране на парола"
|
||||
"reset_password_request_title": "Нулиране на парола",
|
||||
"reset_password_request_description": "Въведете потребителско име или адрес на ел. поща. Ако такъв профил съществува по пощата ще бъде изпратена препратка, от която да смените паролата си.",
|
||||
"reset_password_request_primary_required": "Работи само в случай, че сте задали и потвърдили основен адрес на ел. поща.",
|
||||
"reset_password_request_identifier_label": "Потребителско име или ел. поща",
|
||||
"reset_password_request_button_submit": "Изпращане",
|
||||
"reset_password_sent_title": "Проверете входящата поща",
|
||||
"reset_password_sent_description": "Ако такъв профил съществува на пощата е изпратена препратка, от която да смените паролата си.",
|
||||
"reset_password_back_to_login": "Към вписване",
|
||||
"reset_password_disabled": "Смяната на парола е изключена",
|
||||
"reset_password_title": "Задаване на нова парола",
|
||||
"reset_password_form_password": "Нова парола",
|
||||
"reset_password_form_confirm": "Потвърждаване на новата парола",
|
||||
"reset_password_form_button_submit": "Задаване на парола",
|
||||
"reset_password_form_error_invalid": "Препратката е неприемлива или изтекла. Поискайте нова.",
|
||||
"reset_password_success_title": "Паролата е променена",
|
||||
"reset_password_success_description": "Паролата е сменена. Можете да се впишете с нея.",
|
||||
"action_bar_reload": "Презареждане на приложението",
|
||||
"account_basics_emails_title": "Адреси на ел. поща",
|
||||
"account_basics_emails_description": "За известия и промяна на праролата",
|
||||
"account_basics_emails_no_emails_yet": "Не са зададени ел. адреси",
|
||||
"account_basics_emails_copied_to_clipboard": "Адресът на ел. поща е копиран",
|
||||
"account_basics_emails_chip_actions_primary": "Основен адрес, използван като ел. адрес по подразбиране. Щракнете за действия.",
|
||||
"account_basics_emails_chip_actions_verified": "Може да бъде използван за известия. Щракнете за действия.",
|
||||
"account_basics_emails_chip_actions_unverified": "Непотвърден адрес, проверете входящата поща. Щракнете за действия.",
|
||||
"account_basics_emails_unverified": "непотвърден",
|
||||
"account_basics_emails_set_primary": "Задаване като основен",
|
||||
"account_basics_emails_delete": "Премахване",
|
||||
"account_basics_emails_resend": "Повторно изпращане на писмо за потвърждаване",
|
||||
"account_basics_emails_resent": "Писмото е изпратено, проверете входящата поща",
|
||||
"account_basics_emails_primary_elsewhere": "Този адрес на ел. поща е използван като основен адрес на друг профил",
|
||||
"account_basics_emails_no_recovery_warning": "Добавете адрес на ел. поща, за да можете да възстановите профила ако забравите паролата.",
|
||||
"account_basics_emails_no_primary_warning": "Добавете основен адрес на ел. поща, за да можете да възстановите профила ако забравите паролата.",
|
||||
"account_basics_emails_dialog_title": "Добавяне адреси на ел. поща",
|
||||
"account_basics_emails_dialog_description": "Въведете адрес на ел. поща, който да бъде добавен към профила. Ще бъде изпратена препратка за потвърждение.",
|
||||
"account_basics_emails_dialog_email_label": "Адрес на ел. поща",
|
||||
"account_basics_emails_dialog_email_placeholder": "напр. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Препратка за потвърждаване",
|
||||
"account_basics_emails_dialog_check_inbox": "Проверете входящата поща и посетете препратката за потвърждаване. Докато не го направите адресът ще стои непотвърден.",
|
||||
"account_basics_tier_provisioned": "Потребител от външна система",
|
||||
"account_usage_emails_none": "Профилът не може да изпраща известия по ел. поща",
|
||||
"prefs_users_dialog_base_url_invalid": "Неприемлив формат на адрес. Трябва да започва с http:// или https://",
|
||||
"prefs_users_dialog_base_url_exists": "Потребител за този адрес на услуга вече съществува"
|
||||
}
|
||||
|
||||
@@ -406,5 +406,25 @@
|
||||
"web_push_unknown_notification_title": "Notificación desconocida recibida del servidor",
|
||||
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Un usuario provisionado no se puede editar o eliminar",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "No se puede editar o eliminar un token provisionado"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "No se puede editar o eliminar un token provisionado",
|
||||
"common_refresh": "Recargar",
|
||||
"email_verify_progress_title": "Verificando tu correo...",
|
||||
"email_verify_success_title": "Correo verificado",
|
||||
"email_verify_success_description": "Tu correo electronico ha sido verificado y añadido a tu cuenta.",
|
||||
"email_verify_error_title": "La verificacion falló",
|
||||
"email_verify_error_description": "Este enlace de verificacion es invalido o ha expirado. Puedes solicitar unon nuevo desde los ajustes de tu cuenta.",
|
||||
"email_verify_button_account": "Ir a cuenta",
|
||||
"version_update_available_title": "Nueva versión disponible",
|
||||
"version_update_available_description": "El servidor ntfy ha sido actualizado. Por favor recarga la página.",
|
||||
"signup_form_email": "Correo (opcional, para recuperación de cuenta)",
|
||||
"login_link_forgot_password": "Olvidé mi contraseña",
|
||||
"reset_password_request_title": "Reiniciar contraseña",
|
||||
"reset_password_request_description": "Introduce tu usuario o correo electrónico. Si una cuenta existe, un enlace para reiniciar tu contraseña será enviado a tu correo.",
|
||||
"reset_password_request_primary_required": "Esto solo funciona si ya añadiste un correo eletrónico primario y lo verificaste.",
|
||||
"reset_password_request_identifier_label": "Usuario o correo",
|
||||
"reset_password_request_button_submit": "Enviar enlace de reinicio",
|
||||
"reset_password_sent_title": "Checa tu bandeja de entrada",
|
||||
"reset_password_sent_description": "Si una cuenta existe, un enlace para reiniciar tu contraseña se acaba de enviar a tu correo.",
|
||||
"reset_password_back_to_login": "Regresar a iniciar sesión",
|
||||
"reset_password_disabled": "Reinicio de contraseña ha sido deshabilitado"
|
||||
}
|
||||
|
||||
@@ -329,11 +329,11 @@
|
||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Wyślij SMS",
|
||||
"account_tokens_dialog_expires_never": "Token nigdy nie wygasa",
|
||||
"account_tokens_dialog_expires_x_days": "Token wygasa za {{days}} dni",
|
||||
"account_basics_phone_numbers_dialog_description": "Aby używać funkcji powiadomień telefonicznych, musisz dodać i zweryfikować no najmniej jeden numer telefonu. Weryfikacja może być dokonana przez SMS lub połączenie telefoniczne.",
|
||||
"account_basics_phone_numbers_dialog_description": "Aby korzystać z funkcji powiadomień o połączeniach, należy dodać i zweryfikować co najmniej jeden numer telefonu. Weryfikacji można dokonać za pomocą wiadomości SMS lub połączenia telefonicznego.",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "Dzienne wiadomości: {{messages}}",
|
||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Brak numerów telefonów",
|
||||
"account_tokens_delete_dialog_title": "Usuń token dostępu",
|
||||
"publish_dialog_chip_call_label": "Rozmowa telefoniczna",
|
||||
"publish_dialog_chip_call_label": "Połączenie telefoniczne",
|
||||
"account_basics_phone_numbers_dialog_title": "Dodaj numer telefonu",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "Zarezerwowane tematy: {{reservations}}",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Wybrany plan zezwala na mniejszą liczbę zarezerwowanych tematów niż obecny. Przed zmianą planu, <strong>usuń co najmniej jedną rezerwację</strong>. Rezerwacje możesz usunąć w <Link>Ustawieniach</Link>.",
|
||||
@@ -341,7 +341,7 @@
|
||||
"account_upgrade_dialog_cancel_warning": "To <strong>anuluje Twoją subskrypcję</strong> i obniży status Twojego konta {{date}}. Tego dnia rezerwacja tematów oraz wiadomości przechowywane na serwerze <strong>zostaną usunięte</strong>.",
|
||||
"account_tokens_dialog_expires_x_hours": "Token wygasa za {{hours}} godzin(y)",
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Brak zweryfikowanych numerów telefonów",
|
||||
"publish_dialog_call_label": "Rozmowa telefoniczna",
|
||||
"publish_dialog_call_label": "Połączenie telefoniczne",
|
||||
"account_usage_calls_title": "Wykonane połączenia telefoniczne",
|
||||
"account_basics_phone_numbers_copied_to_clipboard": "Numer telefonu skopiowany do schowka",
|
||||
"account_basics_phone_numbers_dialog_number_placeholder": "np. +1222333444",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+3
-3
@@ -61,7 +61,7 @@ const handlePushMessage = async (data) => {
|
||||
topicRoute: new URL(message.topic, self.location.origin).toString(),
|
||||
baseUrl: subscription.baseUrl,
|
||||
topic: subscription.topic,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Delete existing notification with same sequence ID (if any)
|
||||
@@ -417,7 +417,7 @@ self.addEventListener("notificationclick", (event) => {
|
||||
// [{"revision":"aaabbbcccdddeeefff12345","url":"/index.html"},...]
|
||||
precacheAndRoute(
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
self.__WB_MANIFEST
|
||||
self.__WB_MANIFEST,
|
||||
);
|
||||
|
||||
// Claim all open windows
|
||||
@@ -441,7 +441,7 @@ if (!import.meta.env.DEV) {
|
||||
// the app root itself, could be /, or not
|
||||
new RegExp(`^${config.app_root}$`),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// the manifest excludes config.js (see vite.config.js) since the dist-file differs from the
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("AccountApi.create", () => {
|
||||
await accountApi.create("phil", "pw");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/v1/account",
|
||||
expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "phil", password: "pw", email: "" }) })
|
||||
expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "phil", password: "pw", email: "" }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,7 @@ describe("AccountApi.sync", () => {
|
||||
notification: { sound: "ding", delete_after: 3600, min_priority: 3 },
|
||||
subscriptions: [{ topic: "t" }],
|
||||
reservations: [{ topic: "t" }],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await accountApi.sync();
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("Api.publish", () => {
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ topic: "mytopic", message: "Hello", priority: 5, tags: ["warning"] }),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("Api web push", () => {
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ endpoint: "https://push.example/abc", auth: "AUTH", p256dh: "P256", topics: ["topicA", "topicB"] }),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("Api web push", () => {
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ endpoint: "https://push.example/abc" }),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ class Connection {
|
||||
this.ws.onclose = (event) => {
|
||||
if (event.wasClean) {
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`,
|
||||
);
|
||||
this.ws = null;
|
||||
} else {
|
||||
|
||||
@@ -70,13 +70,13 @@ class ConnectionManager {
|
||||
user,
|
||||
since,
|
||||
(subId, notification) => this.notificationReceived(subId, notification),
|
||||
(subId, state) => this.stateChanged(subId, state)
|
||||
(subId, state) => this.stateChanged(subId, state),
|
||||
);
|
||||
this.connections.set(connectionId, connection);
|
||||
console.log(
|
||||
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
|
||||
user ? user.username : "anonymous"
|
||||
})`
|
||||
})`,
|
||||
);
|
||||
connection.start();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class Notifier {
|
||||
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
|
||||
baseUrl: subscription.baseUrl,
|
||||
topic: subscription.topic,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class Poller {
|
||||
} catch (e) {
|
||||
console.log(`[Poller] Error polling ${s.id}`, e);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class Poller {
|
||||
if (deletedSequenceIds.length > 0) {
|
||||
console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds);
|
||||
await Promise.all(
|
||||
deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId))
|
||||
deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export class SubscriptionManager {
|
||||
subscriptions.map(async (s) => ({
|
||||
...s,
|
||||
new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class SubscriptionManager {
|
||||
});
|
||||
|
||||
return local.id;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Remove local subscriptions that do not exist remotely
|
||||
@@ -134,7 +134,7 @@ export class SubscriptionManager {
|
||||
if (!local.internal && !remoteExists) {
|
||||
await this.remove(local);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export class SubscriptionManager {
|
||||
|
||||
if (!browserSubscription) {
|
||||
console.log(
|
||||
"[SubscriptionManager] No browser subscription currently exists, so web push was never enabled or the notification permission was removed. Skipping."
|
||||
"[SubscriptionManager] No browser subscription currently exists, so web push was never enabled or the notification permission was removed. Skipping.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("throwAppError", () => {
|
||||
await expect(throwAppError(fakeResponse(409, { code: UserExistsError.CODE }))).rejects.toBeInstanceOf(UserExistsError);
|
||||
await expect(throwAppError(fakeResponse(409, { code: TopicReservedError.CODE }))).rejects.toBeInstanceOf(TopicReservedError);
|
||||
await expect(throwAppError(fakeResponse(429, { code: AccountActionLimitReachedError.CODE }))).rejects.toBeInstanceOf(
|
||||
AccountActionLimitReachedError
|
||||
AccountActionLimitReachedError,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("fetchOrThrow", () => {
|
||||
const response = { status: 200 };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => response)
|
||||
vi.fn(async () => response),
|
||||
);
|
||||
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).resolves.toBe(response);
|
||||
});
|
||||
@@ -86,7 +86,7 @@ describe("fetchOrThrow", () => {
|
||||
it("throws on a non-200 response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => fakeResponse(401))
|
||||
vi.fn(async () => fakeResponse(401)),
|
||||
);
|
||||
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ const Layout = () => {
|
||||
const [selected] = (subscriptionsWithoutInternal || []).filter(
|
||||
(s) =>
|
||||
(params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||
|
||||
(config.base_url === s.baseUrl && params.topic === s.topic)
|
||||
(config.base_url === s.baseUrl && params.topic === s.topic),
|
||||
);
|
||||
|
||||
useConnectionListeners(account, subscriptions, users, webPushTopics);
|
||||
|
||||
@@ -80,7 +80,7 @@ const SingleSubscriptionList = (props) => {
|
||||
// getAllNotifications() filtered by id), so topic switches are instant.
|
||||
const notifications = useMemo(
|
||||
() => allNotifications.filter((notification) => notification.subscriptionId === subscription.id),
|
||||
[allNotifications, subscription.id]
|
||||
[allNotifications, subscription.id],
|
||||
);
|
||||
if (notifications.length === 0) {
|
||||
return <NoNotifications subscription={subscription} />;
|
||||
@@ -104,7 +104,7 @@ const NotificationList = (props) => {
|
||||
main.scrollTo(0, 0);
|
||||
}
|
||||
},
|
||||
[props.id]
|
||||
[props.id],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -319,7 +319,7 @@ const Attachment = (props) => {
|
||||
infos.push(
|
||||
t("notifications_attachment_link_expires", {
|
||||
date: formatDateTime(attachment.expires, dateFormat, timeFormat),
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (expired) {
|
||||
|
||||
@@ -168,7 +168,7 @@ const PublishDialog = (props) => {
|
||||
loaded: formatBytes(ev.loaded),
|
||||
total: formatBytes(ev.total),
|
||||
percent: Math.round((ev.loaded * 100.0) / ev.total),
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setStatus(t("publish_dialog_progress_uploading"));
|
||||
@@ -201,19 +201,19 @@ const PublishDialog = (props) => {
|
||||
t("publish_dialog_attachment_limits_file_and_quota_reached", {
|
||||
fileSizeLimit: formatBytes(fileSizeLimit),
|
||||
remainingBytes: formatBytes(remainingBytes),
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else if (fileSizeLimitReached) {
|
||||
setAttachFileError(
|
||||
t("publish_dialog_attachment_limits_file_reached", {
|
||||
fileSizeLimit: formatBytes(fileSizeLimit),
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else if (quotaReached) {
|
||||
setAttachFileError(
|
||||
t("publish_dialog_attachment_limits_quota_reached", {
|
||||
remainingBytes: formatBytes(remainingBytes),
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setAttachFileError("");
|
||||
|
||||
@@ -93,7 +93,7 @@ const SubscribePage = (props) => {
|
||||
const { topic } = props;
|
||||
const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic));
|
||||
const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter(
|
||||
(s) => s !== config.base_url
|
||||
(s) => s !== config.base_url,
|
||||
);
|
||||
const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account);
|
||||
const reserveTopicEnabled =
|
||||
@@ -113,7 +113,7 @@ const SubscribePage = (props) => {
|
||||
setError(
|
||||
t("subscribe_dialog_error_user_not_authorized", {
|
||||
username,
|
||||
})
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,14 +120,14 @@ export const SubscriptionPopup = (props) => {
|
||||
`Hello friend, this is a test notification from ntfy web. It's ${formatDateTime(
|
||||
nowSeconds,
|
||||
dateFormat,
|
||||
timeFormat
|
||||
timeFormat,
|
||||
)} right now. Is that early or late?`,
|
||||
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
|
||||
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
|
||||
`Alright then, it's ${formatDateTime(
|
||||
nowSeconds,
|
||||
dateFormat,
|
||||
timeFormat
|
||||
timeFormat,
|
||||
)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
|
||||
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
|
||||
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
|
||||
|
||||
@@ -30,7 +30,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
||||
// wsSubscriptions should stay stable unless the list of subscription IDs changes. Without the memo, the connection
|
||||
// listener calls a refresh for no reason. This isn't a problem due to the makeConnectionId, but it triggers an
|
||||
// unnecessary recomputation for every received message.
|
||||
[JSON.stringify({ subscriptions: subscriptions?.map(({ id }) => id), webPushTopics })]
|
||||
[JSON.stringify({ subscriptions: subscriptions?.map(({ id }) => id), webPushTopics })],
|
||||
);
|
||||
|
||||
// Register listeners for incoming messages, and connection state changes
|
||||
@@ -103,7 +103,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
||||
},
|
||||
// We have to disable dep checking for "navigate". This is fine, it never changes.
|
||||
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
// Sync topic listener: For accounts with sync_topic, subscribe to an internal topic
|
||||
@@ -233,7 +233,7 @@ export const useWebPushTopics = () => {
|
||||
const topics = useLiveQuery(
|
||||
async () => subscriptionManager.webPushTopics(pushPossible),
|
||||
// invalidate (reload) query when these values change
|
||||
[pushPossible]
|
||||
[pushPossible],
|
||||
);
|
||||
|
||||
useWebPushListener(topics);
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ export default defineConfig(({ mode }) => ({
|
||||
...entry,
|
||||
url: "app.html",
|
||||
}
|
||||
: entry
|
||||
: entry,
|
||||
),
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user