mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-09 13:25:20 +00:00
Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
869f005136 | ||
|
|
d2507dbeed | ||
|
|
ae27172f8d | ||
|
|
e18329a17e | ||
|
|
104182a8be | ||
|
|
7614405332 | ||
|
|
4196e6444c | ||
|
|
a2dc290f31 | ||
|
|
0e2c459d6b | ||
|
|
2f4afbdae5 | ||
|
|
204723f3c0 | ||
|
|
301be79f0a | ||
|
|
03d405ed80 | ||
|
|
4b87a27326 | ||
|
|
d987796243 | ||
|
|
c841caa3b3 | ||
|
|
6310e3a96f | ||
|
|
62a812b742 | ||
|
|
5c5766b031 | ||
|
|
2d2b1635fe | ||
|
|
c51a3c0cb1 | ||
|
|
6481713626 | ||
|
|
561a44b29b | ||
|
|
edc504b47a | ||
|
|
0635e1efdb | ||
|
|
8831d2f87f | ||
|
|
2215479294 | ||
|
|
aab944fc39 | ||
|
|
a829d1a4e1 | ||
|
|
965942844a | ||
|
|
4dd7d1cd62 | ||
|
|
f22f913590 | ||
|
|
f55886e3cc | ||
|
|
b908213f02 | ||
|
|
6596551bc1 | ||
|
|
5d6b864130 | ||
|
|
36ab5b3a8b | ||
|
|
9f217d9d40 | ||
|
|
360b40ec07 | ||
|
|
a3f0f6cfa0 | ||
|
|
0c819a003d | ||
|
|
ef0dde8aa4 | ||
|
|
85abd40658 | ||
|
|
fda636fe34 | ||
|
|
25520c4505 | ||
|
|
6796f6147b | ||
|
|
134c4dd079 | ||
|
|
47044c632e | ||
|
|
9302697a07 | ||
|
|
36ba1650ed | ||
|
|
a1d880aab9 | ||
|
|
acb4c1b3cc | ||
|
|
19bcf0f658 | ||
|
|
29113402ce | ||
|
|
160c916ce0 | ||
|
|
578abdfe08 | ||
|
|
b5ff765bb7 | ||
|
|
f24bf7b51a | ||
|
|
9ccad9da2e | ||
|
|
df2ce34dc0 | ||
|
|
d6397fc5e5 | ||
|
|
c15a242d1a | ||
|
|
c6e252b3d6 | ||
|
|
bdad542fc0 | ||
|
|
3758472345 | ||
|
|
3af7087af3 | ||
|
|
1af07233a9 | ||
|
|
c037e78bd6 | ||
|
|
8f2f69a512 | ||
|
|
a5cf3c0b74 | ||
|
|
6ba3b7c8be | ||
|
|
802c0a4c30 | ||
|
|
33a67cf05b | ||
|
|
8fb7f61dea | ||
|
|
4fbb8441ee | ||
|
|
9fc96bfb8f | ||
|
|
fe8c9b8f2c | ||
|
|
5ad2431dc3 | ||
|
|
2401e183d2 | ||
|
|
fa83d68754 | ||
|
|
1956c88392 | ||
|
|
eefd3d1a54 | ||
|
|
e10ece9715 | ||
|
|
ac09f9802b | ||
|
|
6e90b16d0d | ||
|
|
6cfadf9681 | ||
|
|
59229adf31 | ||
|
|
517bc45f1c | ||
|
|
e1dde9f385 | ||
|
|
a063e2bb35 | ||
|
|
79e70c9f62 | ||
|
|
55b27260cf | ||
|
|
11ff36a19e | ||
|
|
5c9e29ba1c | ||
|
|
5d93cb400a | ||
|
|
4b0a4eee3b | ||
|
|
795ef9da1c | ||
|
|
7e16203065 | ||
|
|
c11991a91d | ||
|
|
85cc652449 | ||
|
|
ec494aead3 | ||
|
|
1b948e9dfa | ||
|
|
ad7dc1bf5e | ||
|
|
4c3968eaba | ||
|
|
cc61d79313 | ||
|
|
9a2b93f7b2 | ||
|
|
05e0e4ed05 | ||
|
|
a47835f21f | ||
|
|
36b76eb318 | ||
|
|
eb624f4bc5 | ||
|
|
4417b951cc | ||
|
|
17ec63df77 | ||
|
|
ffa22fc24b | ||
|
|
6a7c1c47aa |
@@ -0,0 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -8,13 +8,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: '1.25.x'
|
||||
go-version: '1.26.x'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout ntfy code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: binwiederhier/ntfy-docs.github.io
|
||||
path: build/ntfy-docs.github.io
|
||||
|
||||
@@ -25,19 +25,19 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: '1.25.x'
|
||||
go-version: '1.26.x'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './web/package-lock.json'
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
@@ -25,13 +25,13 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: '1.25.x'
|
||||
go-version: '1.26.x'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -45,6 +45,9 @@ ADD ./db ./db
|
||||
ADD ./message ./message
|
||||
ADD ./model ./model
|
||||
ADD ./webpush ./webpush
|
||||
ADD ./attachment ./attachment
|
||||
ADD ./mail ./mail
|
||||
ADD ./s3 ./s3
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||
|
||||
FROM alpine
|
||||
|
||||
@@ -151,7 +151,7 @@ web-deps:
|
||||
# If this fails for .svg files, optimize them with svgo
|
||||
|
||||
web-deps-update:
|
||||
cd web && $(NPM) update
|
||||
cd web && $(NPM) update --before="$(shell date -d '7 days ago' +%Y-%m-%d)"
|
||||
cd web && $(NPM) install
|
||||
|
||||
web-fmt:
|
||||
|
||||
@@ -52,6 +52,7 @@ var flagsServe = append(
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "auth-access-cache", Aliases: []string{"auth_access_cache"}, EnvVars: []string{"NTFY_AUTH_ACCESS_CACHE"}, Value: user.DefaultAccessCacheEnabled, Usage: "enables the in-memory ACL cache (high-volume servers only)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
@@ -93,6 +94,8 @@ var flagsServe = append(
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-topic-creation-limit-burst", Aliases: []string{"visitor_topic_creation_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST"}, Value: server.DefaultVisitorTopicCreationLimitBurst, Usage: "burst of new topic creations per visitor (0 = disabled)"}),
|
||||
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.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)"}),
|
||||
@@ -166,6 +169,7 @@ func execServe(c *cli.Context) error {
|
||||
authUsersRaw := c.StringSlice("auth-users")
|
||||
authAccessRaw := c.StringSlice("auth-access")
|
||||
authTokensRaw := c.StringSlice("auth-tokens")
|
||||
authAccessCacheEnabled := c.Bool("auth-access-cache")
|
||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||
@@ -207,6 +211,8 @@ func execServe(c *cli.Context) error {
|
||||
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
|
||||
visitorTopicCreationLimitBurst := c.Int("visitor-topic-creation-limit-burst")
|
||||
visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish")
|
||||
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
|
||||
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
@@ -252,6 +258,10 @@ func execServe(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr)
|
||||
}
|
||||
visitorTopicCreationLimitReplenish, err := util.ParseDuration(visitorTopicCreationLimitReplenishStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid visitor topic creation limit replenish: %s", visitorTopicCreationLimitReplenishStr)
|
||||
}
|
||||
webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr)
|
||||
@@ -460,6 +470,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.AuthUsers = authUsers
|
||||
conf.AuthAccess = authAccess
|
||||
conf.AuthTokens = authTokens
|
||||
conf.AuthAccessCacheEnabled = authAccessCacheEnabled
|
||||
conf.AttachmentCacheDir = attachmentCacheDir
|
||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||
@@ -497,6 +508,8 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.VisitorTopicCreationLimitBurst = visitorTopicCreationLimitBurst
|
||||
conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish
|
||||
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
|
||||
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
|
||||
conf.BehindProxy = behindProxy
|
||||
|
||||
@@ -378,6 +378,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
||||
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
|
||||
BcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||
AccessCacheEnabled: false, // Do not cache for CLI commands
|
||||
}
|
||||
if databaseURL != "" {
|
||||
host, dbErr := pg.Open(databaseURL)
|
||||
|
||||
+5
-3
@@ -1,14 +1,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/test"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_User_Add(t *testing.T) {
|
||||
@@ -128,6 +129,7 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config,
|
||||
conf.File = configFile
|
||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||
conf.AuthDefault = user.PermissionDenyAll
|
||||
conf.AuthAccessCacheEnabled = false
|
||||
s, port = test.StartServerWithConfig(t, conf)
|
||||
return
|
||||
}
|
||||
|
||||
+83
-2
@@ -1405,7 +1405,7 @@ or the root domain:
|
||||
}
|
||||
```
|
||||
|
||||
=== "Apache2"
|
||||
=== "Apache >= 2.4.47"
|
||||
```
|
||||
# /etc/apache2/sites-*/ntfy.conf
|
||||
|
||||
@@ -1413,6 +1413,7 @@ or the root domain:
|
||||
ServerName ntfy.sh
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy proxy_http")
|
||||
# Use mod_proxy_http for websocket upgrade ('upgrade=websocket'), which requires Apache (httpd) >= 2.4.47.
|
||||
ProxyPass / http://127.0.0.1:2586/ upgrade=websocket
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
@@ -1439,6 +1440,7 @@ or the root domain:
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy proxy_http")
|
||||
# Use mod_proxy_http for websocket upgrade ('upgrade=websocket'), which requires Apache (httpd) >= 2.4.47.
|
||||
ProxyPass / http://127.0.0.1:2586/ upgrade=websocket
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
@@ -1451,6 +1453,68 @@ or the root domain:
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
=== "Apache < 2.4.47"
|
||||
```
|
||||
# /etc/apache2/sites-*/ntfy.conf
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName ntfy.sh
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||
ProxyPass / http://127.0.0.1:2586/
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||
RewriteEngine on
|
||||
# WebSockets support (requires "a2enmod proxy_wstunnel")
|
||||
# mod_proxy_wstunnel is deprecated as of Apache (httpd) 2.4.47. It also uses more resources since it relies on mod_rewrite.
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix (requires "a2enmod alias")
|
||||
<If "%{REQUEST_METHOD} == 'GET'">
|
||||
RedirectMatch permanent "^/([-_A-Za-z0-9]{0,64})$" "https://%{SERVER_NAME}/$1"
|
||||
</If>
|
||||
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName ntfy.sh
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||
ProxyPass / http://127.0.0.1:2586/
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||
RewriteEngine on
|
||||
# WebSockets support (requires "a2enmod proxy_wstunnel")
|
||||
# mod_proxy_wstunnel is deprecated as of Apache (httpd) 2.4.47. It also uses more resources since it relies on mod_rewrite.
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
=== "caddy"
|
||||
```
|
||||
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||
@@ -1855,6 +1919,17 @@ are enabled):
|
||||
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
|
||||
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
|
||||
|
||||
### Topic creation limits
|
||||
To mitigate topic-enumeration / squatting attacks (where a single source pokes thousands of guessable
|
||||
topic names to inflate the server's in-memory topic map), there is a per-visitor limit on how many *new*
|
||||
topics each visitor can cause to be created. Touching topics that already exist in memory does not consume
|
||||
a token; only first-time insertions do.
|
||||
|
||||
* `visitor-topic-creation-limit-burst` is the initial bucket of new-topic tokens. Set to 0 to disable
|
||||
the limit entirely. Defaults to 100.
|
||||
* `visitor-topic-creation-limit-replenish` is the rate at which the bucket is refilled (one new topic per x).
|
||||
Defaults to 1m.
|
||||
|
||||
### Firebase limits
|
||||
If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no`
|
||||
is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling)
|
||||
@@ -2209,6 +2284,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
|
||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). |
|
||||
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||
| `auth-access-cache` | `NTFY_AUTH_ACCESS_CACHE` | *bool* | false | Enables an in-memory ACL cache so authorization checks no longer hit the database. Only worth enabling on high-volume servers. |
|
||||
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
|
||||
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
|
||||
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
|
||||
@@ -2220,7 +2296,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
|
||||
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
|
||||
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
||||
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||
@@ -2245,6 +2321,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||
| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting |
|
||||
| `visitor-topic-creation-limit-burst` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST` | *number* | 100 | Rate limiting: Initial bucket of new topic creations per visitor. 0 disables the limit. |
|
||||
| `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). |
|
||||
| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 |
|
||||
| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
|
||||
@@ -2315,6 +2393,7 @@ OPTIONS:
|
||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
|
||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||
--auth-access-cache, --auth_access_cache enables the in-memory ACL cache (high-volume servers only) (default: false) [$NTFY_AUTH_ACCESS_CACHE]
|
||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]) [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
@@ -2352,6 +2431,8 @@ OPTIONS:
|
||||
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
|
||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||
--visitor-topic-creation-limit-burst value, --visitor_topic_creation_limit_burst value burst of new topic creations per visitor (0 = disabled) (default: 100) [$NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST]
|
||||
--visitor-topic-creation-limit-replenish value, --visitor_topic_creation_limit_replenish value interval at which topic-creation tokens are refilled (one per x) (default: "1m") [$NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH]
|
||||
--visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4]
|
||||
--visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6]
|
||||
--behind-proxy, --behind_proxy, -P if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||
|
||||
@@ -255,6 +255,7 @@ Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-ser
|
||||
go run main.go \
|
||||
--log-level debug \
|
||||
serve \
|
||||
--base-url http://localhost \
|
||||
--web-push-public-key KEY \
|
||||
--web-push-private-key KEY \
|
||||
--web-push-email-address <email> \
|
||||
|
||||
+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.21.0/ntfy_2.21.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.21.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.21.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.23.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.23.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.21.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.21.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.23.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.23.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.21.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.21.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.23.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.23.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.21.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.21.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.23.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.23.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.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.21.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.23.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.21.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.23.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.21.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.23.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.23.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.21.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.23.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.21.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.23.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.21.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.23.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.23.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.21.0/ntfy_2.21.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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.21.0/ntfy_2.21.0_darwin_all.tar.gz > ntfy_2.21.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.21.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.21.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_darwin_all.tar.gz > ntfy_2.23.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.23.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.23.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.21.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.23.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.21.0/ntfy_2.21.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.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`
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
|
||||
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
|
||||
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
|
||||
- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#)
|
||||
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
||||
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
|
||||
@@ -98,6 +99,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
||||
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
|
||||
- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.
|
||||
- [Ntfy App](https://github.com/rubix-studios-pty-ltd/ntfy-app) - Tauri/Rust desktop client for Windows, Linux and MacOS with push notifications.
|
||||
|
||||
## Projects + scripts
|
||||
|
||||
@@ -107,6 +109,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
||||
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
|
||||
- [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay) - ntfy.sh relay for Alertmanager (Go)
|
||||
- [oci-notifications-ntfy-relay](https://github.com/Ryan02I5/oci-notifications-ntfy-relay) - Minimal OCI Notifications / Oracle Functions relay to ntfy topics (Python)
|
||||
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
|
||||
- [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs)
|
||||
- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell)
|
||||
@@ -189,6 +192,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
- [Push alerts for WHM using ntfy](https://rubixstudios.com.au/insights/push-alerts-for-whm-using-ntfy) - rubixstudios.com.au - 5/2026
|
||||
- [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025
|
||||
- [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025
|
||||
- [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Privacy policy
|
||||
|
||||
**Last updated:** January 2, 2026
|
||||
**Last updated:** March 31, 2026
|
||||
|
||||
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
|
||||
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
|
||||
@@ -19,7 +19,8 @@ If you create an account on ntfy.sh, we collect:
|
||||
|
||||
- **Username** - A unique identifier you choose
|
||||
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
|
||||
- **Email address** - Only if you subscribe to a paid plan (for billing purposes)
|
||||
- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified
|
||||
email address for use with the email notification feature
|
||||
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
|
||||
|
||||
You can use ntfy without creating an account. Anonymous usage is fully supported.
|
||||
@@ -143,6 +144,7 @@ No cookies are used for tracking. The web app does not have a backend beyond the
|
||||
| Attachments | 3 hours (configurable by server operators) |
|
||||
| User accounts | Until you delete your account |
|
||||
| Access tokens | Until you revoke them or delete your account |
|
||||
| Email addresses | Until you remove them or delete your account |
|
||||
| Phone numbers | Until you remove them or delete your account |
|
||||
| Web push subscriptions | 60 days of inactivity, then automatically removed |
|
||||
| Server logs | Varies; debugging logs are typically temporary |
|
||||
|
||||
+1
-1
@@ -932,7 +932,7 @@ Here's an example of how it will look on Android:
|
||||
</figure>
|
||||
|
||||
## Attachments
|
||||
_Supported on:_ :material-android: :material-firefox:
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
|
||||
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
|
||||
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
|
||||
|
||||
+117
-6
@@ -6,13 +6,93 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.21.0 | Mar 30, 2026 |
|
||||
| ntfy server | v2.23.0 | May 17, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||
| 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.21.0
|
||||
## ntfy iOS app v1.7.0
|
||||
Released May 30, 2026
|
||||
|
||||
This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS
|
||||
feature gaps. Images sent via the `Attach` header (or as a PUT body) are now previewed inline in the notification banner
|
||||
and inside the app, and other attachments can be downloaded, previewed via Quick Look, and shared from the notification
|
||||
row. There's also a new "Download attachments" setting to control auto-download by size.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Show image previews in notifications and inline in the notification list, with tap-to-zoom Quick Look preview and share sheet ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), [#276](https://github.com/binwiederhier/ntfy/issues/276), [#1226](https://github.com/binwiederhier/ntfy/issues/1226), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Download non-image attachments on demand with progress indication, persist them locally, and reuse files already fetched by the notification service extension ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Add "Download attachments" setting with size thresholds (Never, Under 100 KB / 500 KB / 1 MB / 5 MB / 10 MB / 50 MB, Always) to control automatic attachment downloads ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Improve background download reliability so attachments continue downloading when the app is suspended ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Reorganize notification and subscription views into their own folders and split out `NotificationRowView` for readability ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
## ntfy server v2.23.0
|
||||
Released May 17, 2026
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add per-visitor rate limit on new topic creations (`visitor-topic-creation-limit-burst` / `visitor-topic-creation-limit-replenish`, defaults 100 burst / 1m replenish) to mitigate topic-enumeration / squatting attacks that inflate the in-memory topic map
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint
|
||||
* Restrict the publish dialog's local file preview to safe image types (png/jpg/gif/webp) to prevent same-origin script execution from blob URLs when previewing a crafted SVG ([GHSA-j8hr-p342-xrmh](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-j8hr-p342-xrmh), thanks to [@Venukamatchi](https://github.com/Venukamatchi) for reporting)
|
||||
|
||||
## ntfy iOS app v1.6.0
|
||||
Released May 12, 2026
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix crash in iOS v1.5.1 ([#1736](https://github.com/binwiederhier/ntfy/issues/1736), thanks to [@russ-who](https://github.com/russ-who) for reporting and [@am7590](https://github.com/am7590) for fixing)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Tap a notification to open its click URL, or copy the message text if no click URL is set; inline URLs in message text are now tappable as well ([ntfy-ios#37](https://github.com/binwiederhier/ntfy-ios/pull/37), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
## ntfy iOS app v1.5.1
|
||||
Released April 27, 2026
|
||||
|
||||
This release continues the iOS stability push from v1.4.1, with improved background polling reliability, better error
|
||||
handling and logging, and a few small UI fixes. The minimum supported iOS version is now iOS 15.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Bump minimum iOS version to iOS 15 ([ntfy-ios#36](https://github.com/binwiederhier/ntfy-ios/pull/36), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Improve background poll reliability by waiting for polls to finish before calling the fetch completion handler, and saving notifications on the Core Data context queue ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Make `poll_request` parsing more tolerant and surface concrete poll errors instead of failing silently ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Poll subscriptions when the subscribed topics list appears, for more reactive updates ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Fix bug where tapping "Add user" a second time would briefly open and then dismiss the add user view ([ntfy-ios#35](https://github.com/binwiederhier/ntfy-ios/pull/35), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Split `SettingsView` into separate files to improve readability ([ntfy-ios#35](https://github.com/binwiederhier/ntfy-ios/pull/35), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Add Firebase subscribe/unsubscribe logging to aid debugging ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
## ntfy server v2.22.0
|
||||
Released April 21, 2026
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Tighten web push endpoint allow-list regex to prevent SSRF via unanchored pattern matching ([GHSA-w9hq-5jg7-q4j7](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-w9hq-5jg7-q4j7), thanks to [@MightyNawaf](https://github.com/MightyNawaf) for reporting)
|
||||
* Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing)
|
||||
* Fix web app crashing on account page for tokens without a last access time ([#1651](https://github.com/binwiederhier/ntfy/issues/1651), [#1684](https://github.com/binwiederhier/ntfy/issues/1684), thanks to [@Pulsar7](https://github.com/Pulsar7) and [@rzhli](https://github.com/rzhli) for reporting)
|
||||
|
||||
## ntfy iOS app v1.4.1
|
||||
Released April 14, 2026
|
||||
|
||||
This is the first iOS release in 3 years, focusing on stability fixes as per the [iOS improvement plan](https://github.com/binwiederhier/ntfy/issues/1680).
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
## ntfy server v2.21.0
|
||||
Released March 30, 2026
|
||||
|
||||
This release adds the ability to verify email addresses using the `smtp-sender-verify` flag. This is a change that is
|
||||
@@ -24,7 +104,7 @@ ntfy.sh won't be able to send emails unless the email address was verified ahead
|
||||
* Add verified email recipients feature with `smtp-sender-verify` config flag, allowing server admins to require email
|
||||
address verification before sending email notifications ([#1681](https://github.com/binwiederhier/ntfy/pull/1681))
|
||||
|
||||
### ntfy server v2.20.1
|
||||
## ntfy server v2.20.1
|
||||
Released March 27, 2026
|
||||
|
||||
This is a small bugfix release that only affects high volume S3 backends that struggle with HTTP/2.
|
||||
@@ -33,7 +113,7 @@ This is a small bugfix release that only affects high volume S3 backends that st
|
||||
|
||||
* [Attachments](config.md#attachments): Add `disable_http2=true` S3 URL option to work around HTTP/2 stream errors with DigitalOcean Spaces and other S3-compatible providers ([#1678](https://github.com/binwiederhier/ntfy/issues/1678)/[#1679](https://github.com/binwiederhier/ntfy/pull/1679))
|
||||
|
||||
### ntfy server v2.20.0
|
||||
## ntfy server v2.20.0
|
||||
Released March 26, 2026
|
||||
|
||||
This release is another step towards making it possible to help scale ntfy up and out 🔥! With this release, you can store
|
||||
@@ -1844,9 +1924,40 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
## ntfy Android v1.25.x (UNRELEASED)
|
||||
### ntfy server v2.24.0 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add opt-in in-memory ACL cache (`auth-access-cache`) that serves topic authorization without a database round-trip; off by default, intended for high-volume servers
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution)
|
||||
* Fix `rel` attribute on auto-linked notification URLs so `noreferrer`/`noopener` are actually applied ([#1720](https://github.com/binwiederhier/ntfy/pull/1720), thanks to [@dmitrylyzo](https://github.com/dmitrylyzo) for the contribution)
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
|
||||
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
|
||||
|
||||
**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
|
||||
* 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:**
|
||||
|
||||
* 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)
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
module heckel.io/ntfy/v2
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.21.0 // indirect
|
||||
cloud.google.com/go/storage v1.61.3 // indirect
|
||||
cloud.google.com/go/firestore v1.22.0 // indirect
|
||||
cloud.google.com/go/storage v1.62.2 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.18.0
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.38
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.273.0
|
||||
google.golang.org/api v0.282.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -28,29 +28,29 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
|
||||
require github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.19.0
|
||||
firebase.google.com/go/v4 v4.20.0
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cel.dev/expr v0.25.2 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.19.0 // indirect
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.6.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.8.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
cloud.google.com/go/iam v1.11.0 // indirect
|
||||
cloud.google.com/go/longrunning v1.0.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -61,7 +61,7 @@ require (
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
@@ -69,8 +69,8 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.20.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -79,28 +79,27 @@ require (
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/common v0.68.0 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
|
||||
cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
|
||||
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
|
||||
cloud.google.com/go/iam v1.6.0 h1:JiSIcEi38dWBKhB3BtfKCW+dMvCZJEhBA2BsaGJgoxs=
|
||||
cloud.google.com/go/iam v1.6.0/go.mod h1:ZS6zEy7QHmcNO18mjO2viYv/n+wOUkhJqGNkPPGueGU=
|
||||
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
|
||||
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
|
||||
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
|
||||
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
|
||||
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
|
||||
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
|
||||
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
|
||||
cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
|
||||
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
|
||||
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4=
|
||||
cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
|
||||
firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8=
|
||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
@@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -96,10 +96,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs=
|
||||
github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -108,8 +108,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
@@ -120,8 +120,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
|
||||
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -139,8 +139,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA=
|
||||
github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -165,24 +165,26 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
@@ -193,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -209,8 +211,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -234,8 +236,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -245,8 +247,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -258,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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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=
|
||||
@@ -270,20 +272,20 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
|
||||
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
|
||||
google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -69,6 +69,8 @@ const (
|
||||
DefaultVisitorMessageDailyLimit = 0
|
||||
DefaultVisitorEmailLimitBurst = 16
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorTopicCreationLimitBurst = 100
|
||||
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
||||
DefaultVisitorAccountCreationLimitBurst = 3
|
||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAuthFailureLimitBurst = 30
|
||||
@@ -114,6 +116,8 @@ type Config struct {
|
||||
AuthTokens map[string][]*user.Token
|
||||
AuthBcryptCost int
|
||||
AuthStatsQueueWriterInterval time.Duration
|
||||
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
||||
AuthAccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
|
||||
AttachmentCacheDir string
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentFileSizeLimit int64
|
||||
@@ -163,6 +167,8 @@ type Config struct {
|
||||
VisitorMessageDailyLimit int
|
||||
VisitorEmailLimitBurst int
|
||||
VisitorEmailLimitReplenish time.Duration
|
||||
VisitorTopicCreationLimitBurst int // Burst of new topic creations per visitor
|
||||
VisitorTopicCreationLimitReplenish time.Duration // Interval at which topic-creation tokens are refilled
|
||||
VisitorAccountCreationLimitBurst int
|
||||
VisitorAccountCreationLimitReplenish time.Duration
|
||||
VisitorAuthFailureLimitBurst int
|
||||
@@ -219,6 +225,8 @@ func NewConfig() *Config {
|
||||
AuthDefault: user.PermissionReadWrite,
|
||||
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||
AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled,
|
||||
AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval,
|
||||
AttachmentCacheDir: "",
|
||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||
@@ -266,6 +274,8 @@ func NewConfig() *Config {
|
||||
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
|
||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||
VisitorTopicCreationLimitBurst: DefaultVisitorTopicCreationLimitBurst,
|
||||
VisitorTopicCreationLimitReplenish: DefaultVisitorTopicCreationLimitReplenish,
|
||||
VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst,
|
||||
VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
|
||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||
|
||||
@@ -170,6 +170,7 @@ var (
|
||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitTopicCreation = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: too many new topics, please wait", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
||||
|
||||
+31
-21
@@ -247,16 +247,18 @@ func New(conf *Config) (*Server, error) {
|
||||
var userManager *user.Manager
|
||||
if conf.AuthFile != "" || pool != nil {
|
||||
authConfig := &user.Config{
|
||||
Filename: conf.AuthFile,
|
||||
DatabaseURL: conf.DatabaseURL,
|
||||
StartupQueries: conf.AuthStartupQueries,
|
||||
DefaultAccess: conf.AuthDefault,
|
||||
ProvisionEnabled: true, // Enable provisioning of users and access
|
||||
Users: conf.AuthUsers,
|
||||
Access: conf.AuthAccess,
|
||||
Tokens: conf.AuthTokens,
|
||||
BcryptCost: conf.AuthBcryptCost,
|
||||
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
||||
Filename: conf.AuthFile,
|
||||
DatabaseURL: conf.DatabaseURL,
|
||||
StartupQueries: conf.AuthStartupQueries,
|
||||
DefaultAccess: conf.AuthDefault,
|
||||
ProvisionEnabled: true, // Enable provisioning of users and access
|
||||
Users: conf.AuthUsers,
|
||||
Access: conf.AuthAccess,
|
||||
Tokens: conf.AuthTokens,
|
||||
BcryptCost: conf.AuthBcryptCost,
|
||||
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
||||
AccessCacheEnabled: conf.AuthAccessCacheEnabled,
|
||||
AccessCacheReloadInterval: conf.AuthAccessCacheReloadInterval,
|
||||
}
|
||||
if pool != nil {
|
||||
userManager, err = user.NewPostgresManager(pool, authConfig)
|
||||
@@ -1543,7 +1545,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
return errHTTPTooManyRequestsLimitSubscriptions
|
||||
}
|
||||
defer v.RemoveSubscription()
|
||||
topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
|
||||
topics, topicsStr, err := s.topicsFromPath(v, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1646,7 +1648,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
defer v.RemoveSubscription()
|
||||
logvr(v, r).Tag(tagWebsocket).Debug("WebSocket connection opened")
|
||||
defer logvr(v, r).Tag(tagWebsocket).Debug("WebSocket connection closed")
|
||||
topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
|
||||
topics, topicsStr, err := s.topicsFromPath(v, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1916,24 +1918,26 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visito
|
||||
}
|
||||
|
||||
// topicFromPath returns the topic from a root path (e.g. /mytopic), creating it if it doesn't exist.
|
||||
func (s *Server) topicFromPath(path string) (*topic, error) {
|
||||
// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use).
|
||||
func (s *Server) topicFromPath(v *visitor, path string) (*topic, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil, errHTTPBadRequestTopicInvalid
|
||||
}
|
||||
return s.topicFromID(parts[1])
|
||||
return s.topicFromID(v, parts[1])
|
||||
}
|
||||
|
||||
// topicsFromPath returns the topic from a root path (e.g. /mytopic,mytopic2), creating it if it doesn't exist.
|
||||
func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
|
||||
// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use).
|
||||
func (s *Server) topicsFromPath(v *visitor, path string) ([]*topic, string, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil, "", errHTTPBadRequestTopicInvalid
|
||||
}
|
||||
topicIDs := util.SplitNoEmpty(parts[1], ",")
|
||||
topics, err := s.topicsFromIDs(topicIDs...)
|
||||
topics, err := s.topicsFromIDs(v, topicIDs...)
|
||||
if err != nil {
|
||||
return nil, "", errHTTPBadRequestTopicInvalid
|
||||
return nil, "", err
|
||||
}
|
||||
return topics, parts[1], nil
|
||||
}
|
||||
@@ -1948,7 +1952,9 @@ func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
|
||||
}
|
||||
|
||||
// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
|
||||
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||
// If v is non-nil, its per-visitor topic-creation rate limiter is consulted before each new
|
||||
// insertion into the in-memory topic map. Pass nil to bypass the limit (internal use only).
|
||||
func (s *Server) topicsFromIDs(v *visitor, ids ...string) ([]*topic, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
topics := make([]*topic, 0)
|
||||
@@ -1960,6 +1966,9 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||
if len(s.topics) >= s.config.TotalTopicLimit {
|
||||
return nil, errHTTPTooManyRequestsLimitTotalTopics
|
||||
}
|
||||
if v != nil && !v.TopicCreationAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitTopicCreation
|
||||
}
|
||||
s.topics[id] = newTopic(id)
|
||||
}
|
||||
topics = append(topics, s.topics[id])
|
||||
@@ -1968,8 +1977,9 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||
}
|
||||
|
||||
// topicFromID returns the topic with the given ID, creating it if it doesn't exist.
|
||||
func (s *Server) topicFromID(id string) (*topic, error) {
|
||||
topics, err := s.topicsFromIDs(id)
|
||||
// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use).
|
||||
func (s *Server) topicFromID(v *visitor, id string) (*topic, error) {
|
||||
topics, err := s.topicsFromIDs(v, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2239,7 +2249,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun
|
||||
if s.userManager == nil {
|
||||
return next(w, r, v)
|
||||
}
|
||||
topics, _, err := s.topicsFromPath(r.URL.Path)
|
||||
topics, _, err := s.topicsFromPath(v, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@
|
||||
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
|
||||
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
|
||||
# Use 'ntfy token generate' to generate a new access token.
|
||||
# - auth-access-cache enables an in-memory snapshot of the access control table that authorizes every
|
||||
# request without a database round-trip.
|
||||
#
|
||||
# Debian/RPM package users:
|
||||
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||
@@ -131,6 +133,7 @@
|
||||
# auth-users:
|
||||
# auth-access:
|
||||
# auth-tokens:
|
||||
# auth-access-cache: false
|
||||
|
||||
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
|
||||
# the visitor IP address instead of the remote address of the connection.
|
||||
@@ -358,6 +361,15 @@
|
||||
# visitor-email-limit-burst: 16
|
||||
# visitor-email-limit-replenish: "1h"
|
||||
|
||||
# Rate limiting: Allowed new topic creations per visitor. A "creation" is when a request
|
||||
# causes a previously-unknown topic ID to be added to the in-memory topic map. Touches of
|
||||
# existing topics do not consume tokens. Mitigates topic-enumeration / squatting attacks.
|
||||
# - visitor-topic-creation-limit-burst is the initial bucket of new-topic tokens (0 = disabled)
|
||||
# - visitor-topic-creation-limit-replenish is the rate at which the bucket is refilled
|
||||
#
|
||||
# visitor-topic-creation-limit-burst: 100
|
||||
# visitor-topic-creation-limit-replenish: "1m"
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -485,7 +485,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
||||
return err
|
||||
}
|
||||
// Kill existing subscribers
|
||||
t, err := s.topicFromID(req.Topic)
|
||||
t, err := s.topicFromID(v, req.Topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -728,7 +728,7 @@ func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
return nil
|
||||
}
|
||||
logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic")
|
||||
syncTopic, err := s.topicFromID(u.SyncTopic)
|
||||
syncTopic, err := s.topicFromID(nil, u.SyncTopic) // internal: no rate limit
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -361,6 +361,15 @@ func TestAccount_ExtendToken(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "some label", token.Label)
|
||||
require.Equal(t, expires.Unix(), token.Expires)
|
||||
|
||||
body = fmt.Sprintf(`{"token":"%s", "expires": 0}`, token.Token)
|
||||
rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{
|
||||
"Authorization": util.BearerAuth(token.Token),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), token.Expires)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context
|
||||
func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
t, err := s.topicFromPath(r.URL.Path)
|
||||
t, err := s.topicFromPath(v, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+62
-1
@@ -2849,7 +2849,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||
messages := make([]*model.Message, 0)
|
||||
for i := 0; i < count; i++ {
|
||||
topicID := fmt.Sprintf("topic%d", i)
|
||||
_, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array
|
||||
_, err := s.topicsFromIDs(nil, topicID) // Add topic to internal s.topics array
|
||||
require.Nil(t, err)
|
||||
messages = append(messages, model.NewDefaultMessage(topicID, "some message"))
|
||||
}
|
||||
@@ -3148,6 +3148,67 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *t
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_VisitorTopicCreationLimit(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.VisitorTopicCreationLimitBurst = 5
|
||||
c.VisitorTopicCreationLimitReplenish = time.Hour // Effectively no refill during the test
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// First 5 brand-new topics succeed
|
||||
for i := 0; i < 5; i++ {
|
||||
rr := request(t, s, "PUT", fmt.Sprintf("/fresh-topic-%d", i), "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
// 6th brand-new topic is throttled (42911)
|
||||
rr := request(t, s, "PUT", "/fresh-topic-6", "hi", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), `"code":42911`)
|
||||
|
||||
// Republishing to an existing topic doesn't consume a token
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := request(t, s, "PUT", "/fresh-topic-0", "again", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_VisitorTopicCreationLimit_Refill(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.VisitorTopicCreationLimitBurst = 2
|
||||
c.VisitorTopicCreationLimitReplenish = 300 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Burn the burst
|
||||
for i := 0; i < 2; i++ {
|
||||
rr := request(t, s, "PUT", fmt.Sprintf("/refill-topic-%d", i), "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
rr := request(t, s, "PUT", "/refill-topic-blocked", "hi", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
|
||||
// Wait for a token to be replenished
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
rr = request(t, s, "PUT", "/refill-topic-after", "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_VisitorTopicCreationLimit_Disabled(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.VisitorTopicCreationLimitBurst = 0 // 0 disables the limit
|
||||
s := newTestServer(t, c)
|
||||
for i := 0; i < 25; i++ {
|
||||
rr := request(t, s, "PUT", fmt.Sprintf("/nolimit-topic-%d", i), "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfig(t, databaseURL)
|
||||
|
||||
+22
-20
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
@@ -24,37 +23,40 @@ const (
|
||||
webPushTopicSubscribeLimit = 50
|
||||
)
|
||||
|
||||
var (
|
||||
webPushAllowedEndpointsPatterns = []string{
|
||||
"https://*.google.com/",
|
||||
"https://*.googleapis.com/",
|
||||
"https://*.mozilla.com/",
|
||||
"https://*.mozaws.net/",
|
||||
"https://*.windows.com/",
|
||||
"https://*.microsoft.com/",
|
||||
"https://*.apple.com/",
|
||||
}
|
||||
webPushAllowedEndpointsRegex *regexp.Regexp
|
||||
)
|
||||
// webPushAllowedEndpointsRegexes is the host-level allow-list of web push services ntfy
|
||||
// will deliver to. Each regex anchors the scheme and matches the stable service host,
|
||||
// followed by the authority/path boundary "/". Instance-specific labels (e.g. the
|
||||
// "wns2-<region>" prefix on Windows Notification Service hosts) are wildcarded with
|
||||
// a single-label pattern ([^/]+) that cannot span into the path.
|
||||
// See GHSA-w9hq-5jg7-q4j7 for why wildcarding the entire host is insufficient.
|
||||
var webPushAllowedEndpointsRegexes = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^https://fcm\.googleapis\.com/`),
|
||||
regexp.MustCompile(`^https://jmt17\.google\.com/`),
|
||||
regexp.MustCompile(`^https://updates\.push\.services\.mozilla\.com/`),
|
||||
regexp.MustCompile(`^https://[^/]+\.mozaws\.net/`),
|
||||
regexp.MustCompile(`^https://web\.push\.apple\.com/`),
|
||||
regexp.MustCompile(`^https://[^/]+\.notify\.windows\.com/`),
|
||||
}
|
||||
|
||||
func init() {
|
||||
for i, pattern := range webPushAllowedEndpointsPatterns {
|
||||
webPushAllowedEndpointsPatterns[i] = strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".+")
|
||||
func webPushEndpointAllowed(endpoint string) bool {
|
||||
for _, re := range webPushAllowedEndpointsRegexes {
|
||||
if re.MatchString(endpoint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
allPatterns := fmt.Sprintf("^(%s)", strings.Join(webPushAllowedEndpointsPatterns, "|"))
|
||||
webPushAllowedEndpointsRegex = regexp.MustCompile(allPatterns)
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" {
|
||||
return errHTTPBadRequestWebPushSubscriptionInvalid
|
||||
} else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) {
|
||||
} else if !webPushEndpointAllowed(req.Endpoint) {
|
||||
return errHTTPBadRequestWebPushEndpointUnknown
|
||||
} else if len(req.Topics) > webPushTopicSubscribeLimit {
|
||||
return errHTTPBadRequestWebPushTopicCountTooHigh
|
||||
}
|
||||
topics, err := s.topicsFromIDs(req.Topics...)
|
||||
topics, err := s.topicsFromIDs(v, req.Topics...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -87,6 +87,78 @@ func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_EndpointRegex(t *testing.T) {
|
||||
// Synthetic endpoint samples representing each supported push service host shape.
|
||||
allowed := []string{
|
||||
// Google FCM (legacy send, webpush, preprod webpush)
|
||||
"https://fcm.googleapis.com/fcm/send/FAKETOKEN:APA91b-placeholder-not-a-real-token",
|
||||
"https://fcm.googleapis.com/wp/FAKETOKEN:APA91b-placeholder-not-a-real-token",
|
||||
"https://fcm.googleapis.com/preprod/wp/FAKETOKEN:APA91b-placeholder-not-a-real-token",
|
||||
"https://jmt17.google.com/fcm/send/FAKETOKEN:APA91b-placeholder-not-a-real-token",
|
||||
// Mozilla autopush (v1 legacy, v2 current, plus AWS-hosted infra)
|
||||
"https://updates.push.services.mozilla.com/wpush/v1/placeholder-not-a-real-token",
|
||||
"https://updates.push.services.mozilla.com/wpush/v2/placeholder-not-a-real-token",
|
||||
"https://autopush.mozaws.net/wpush/v1/placeholder-not-a-real-token",
|
||||
// Apple Web Push
|
||||
"https://web.push.apple.com/placeholder-not-a-real-token",
|
||||
// Microsoft WNS: instance-specific "wns2-<region>" prefix is wildcarded
|
||||
"https://wns2-bn3p.notify.windows.com/w/?token=placeholder",
|
||||
"https://wns2-ch1p.notify.windows.com/w/?token=placeholder",
|
||||
"https://wns2-par02p.notify.windows.com/w/?token=placeholder",
|
||||
"https://wns2-pn1p.notify.windows.com/w/?token=placeholder",
|
||||
"https://wns2-am3p.notify.windows.com/w/?token=placeholder",
|
||||
}
|
||||
denied := []string{
|
||||
// HTTP (not HTTPS)
|
||||
"http://fcm.googleapis.com/fcm/send/abc",
|
||||
// Unrelated host
|
||||
"https://attacker.example.com/webpush",
|
||||
// GHSA-w9hq-5jg7-q4j7 bypass: allowed host embedded in path
|
||||
"https://attacker.com/x.google.com/push",
|
||||
"https://attacker.example.com/fcm.googleapis.com/fcm/send/abc",
|
||||
"https://evil.test/web.push.apple.com/3/device/abc",
|
||||
"https://ntfytest.requestcatcher.com/path.google.com/push",
|
||||
"https://ntfytest.requestcatcher.com/a.google.com/toto",
|
||||
"https://ntfytest.requestcatcher.com/bypass.google.com/test",
|
||||
"https://webhook.site/86e94e2e-2af4-4a31-a80b-e2f335cc6495/path.google.com/push",
|
||||
"https://webhook.site/86e94e2e-2af4-4a31-a80b-e2f335cc6495/bypass.google.com/",
|
||||
// Allowed host as a prefix of a different host (no separating slash)
|
||||
"https://fcm.googleapis.com.attacker.com/fcm/send/abc",
|
||||
"https://web.push.apple.com.evil.test/tok",
|
||||
// Allowed host as a suffix of a different host (no separating dot)
|
||||
"https://evilgoogle.com/",
|
||||
"https://notapple.com/",
|
||||
// Credentials/userinfo in the URL pointing at a different host
|
||||
"https://fcm.googleapis.com@attacker.com/fcm/send/abc",
|
||||
// Previously allowed by the wildcard allowlist but not actually used by Web Push
|
||||
"https://api.push.apple.com/3/device/abc",
|
||||
"https://android.googleapis.com/send/xyz",
|
||||
"https://login.microsoft.com/anything",
|
||||
// Bare notify.windows.com with no subdomain label
|
||||
"https://notify.windows.com/w/?token=abc",
|
||||
}
|
||||
for _, endpoint := range allowed {
|
||||
require.Truef(t, webPushEndpointAllowed(endpoint), "expected endpoint to be allowed: %s", endpoint)
|
||||
}
|
||||
for _, endpoint := range denied {
|
||||
require.Falsef(t, webPushEndpointAllowed(endpoint), "expected endpoint to be denied: %s", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd_BypassAttempt(t *testing.T) {
|
||||
// Regression test for GHSA-w9hq-5jg7-q4j7: the allow-list regex previously had no
|
||||
// end anchor, so a URL like https://attacker.example.com/x.google.com/... passed
|
||||
// validation and caused the server to deliver push payloads to attacker-controlled
|
||||
// endpoints (SSRF + message exfiltration via attacker-supplied p256dh key).
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://attacker.example.com/x.google.com/push"), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
+61
-41
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -53,22 +54,23 @@ const (
|
||||
|
||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||
type visitor struct {
|
||||
config *Config
|
||||
messageCache *message.Cache
|
||||
userManager *user.Manager // May be nil
|
||||
ip netip.Addr // Visitor IP address
|
||||
user *user.User // Only set if authenticated user, otherwise nil
|
||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
||||
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
||||
callsLimiter *util.FixedLimiter // Rate limiter for calls
|
||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
||||
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)
|
||||
mu sync.RWMutex
|
||||
config *Config
|
||||
messageCache *message.Cache
|
||||
userManager *user.Manager // May be nil
|
||||
ip netip.Addr // Visitor IP address
|
||||
user *user.User // Only set if authenticated user, otherwise nil
|
||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
||||
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
||||
callsLimiter *util.FixedLimiter // Rate limiter for calls
|
||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||
topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map
|
||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
||||
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)
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type visitorInfo struct {
|
||||
@@ -123,21 +125,22 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man
|
||||
calls = user.Stats.Calls
|
||||
}
|
||||
v := &visitor{
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
userManager: userManager, // May be nil
|
||||
ip: ip,
|
||||
user: user,
|
||||
firebase: time.Unix(0, 0),
|
||||
seen: time.Now(),
|
||||
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||
requestLimiter: nil, // Set in resetLimiters
|
||||
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
||||
emailsLimiter: nil, // Set in resetLimiters
|
||||
callsLimiter: nil, // Set in resetLimiters, may be nil
|
||||
bandwidthLimiter: nil, // Set in resetLimiters
|
||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
userManager: userManager, // May be nil
|
||||
ip: ip,
|
||||
user: user,
|
||||
firebase: time.Unix(0, 0),
|
||||
seen: time.Now(),
|
||||
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||
requestLimiter: nil, // Set in resetLimiters
|
||||
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
||||
emailsLimiter: nil, // Set in resetLimiters
|
||||
callsLimiter: nil, // Set in resetLimiters, may be nil
|
||||
topicCreationLimiter: nil, // Set in resetLimiters
|
||||
bandwidthLimiter: nil, // Set in resetLimiters
|
||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||
}
|
||||
v.resetLimitersNoLock(messages, emails, calls, false)
|
||||
return v
|
||||
@@ -152,14 +155,13 @@ func (v *visitor) Context() log.Context {
|
||||
func (v *visitor) contextNoLock() log.Context {
|
||||
info := v.infoLightNoLock()
|
||||
fields := log.Context{
|
||||
"visitor_id": visitorID(v.ip, v.user, v.config),
|
||||
"visitor_ip": v.ip.String(),
|
||||
"visitor_seen": util.FormatTime(v.seen),
|
||||
"visitor_messages": info.Stats.Messages,
|
||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||
"visitor_request_limiter_limit": v.requestLimiter.Limit(),
|
||||
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
|
||||
"visitor_id": visitorID(v.ip, v.user, v.config),
|
||||
"visitor_ip": v.ip.String(),
|
||||
"visitor_seen": util.FormatTime(v.seen),
|
||||
"visitor_messages": info.Stats.Messages,
|
||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||
"visitor_requests_remaining": int64(math.Floor(v.requestLimiter.Tokens())),
|
||||
}
|
||||
if v.config.SMTPSenderFrom != "" {
|
||||
fields["visitor_emails"] = info.Stats.Emails
|
||||
@@ -172,8 +174,10 @@ func (v *visitor) contextNoLock() log.Context {
|
||||
fields["visitor_calls_remaining"] = info.Stats.CallsRemaining
|
||||
}
|
||||
if v.authLimiter != nil {
|
||||
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
|
||||
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
|
||||
fields["visitor_auth_attempts_remaining"] = int64(math.Floor(v.authLimiter.Tokens()))
|
||||
}
|
||||
if v.topicCreationLimiter != nil {
|
||||
fields["visitor_topic_creations_remaining"] = int64(math.Floor(v.topicCreationLimiter.Tokens()))
|
||||
}
|
||||
if v.user != nil {
|
||||
fields["user_id"] = v.user.ID
|
||||
@@ -246,6 +250,17 @@ func (v *visitor) SubscriptionAllowed() bool {
|
||||
return v.subscriptionLimiter.Allow()
|
||||
}
|
||||
|
||||
// TopicCreationAllowed returns true if the visitor is allowed to cause a new topic to be
|
||||
// inserted into the server's in-memory topic map. Returns true if no limiter is configured.
|
||||
func (v *visitor) TopicCreationAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.topicCreationLimiter == nil {
|
||||
return true
|
||||
}
|
||||
return v.topicCreationLimiter.Allow()
|
||||
}
|
||||
|
||||
// AuthAllowed returns true if an auth request can be attempted (> 1 token available)
|
||||
func (v *visitor) AuthAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
@@ -385,6 +400,11 @@ func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpda
|
||||
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
|
||||
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
|
||||
v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls)
|
||||
if v.config.VisitorTopicCreationLimitBurst > 0 && v.config.VisitorTopicCreationLimitReplenish > 0 {
|
||||
v.topicCreationLimiter = rate.NewLimiter(rate.Every(v.config.VisitorTopicCreationLimitReplenish), v.config.VisitorTopicCreationLimitBurst)
|
||||
} else {
|
||||
v.topicCreationLimiter = nil // Disabled
|
||||
}
|
||||
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
|
||||
if v.user == nil {
|
||||
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
// accessCache is an in-memory index over the entire user_access table.
|
||||
//
|
||||
// exact[username][escapedTopic] returns the matching entry in O(1) for the common
|
||||
// case where the requested topic appears verbatim in some rule. The key is the
|
||||
// stored form of the topic (i.e. with \_ escapes), so Lookup escapes incoming
|
||||
// topics through escapeUnderscore before probing.
|
||||
//
|
||||
// pattern[username] is the linear-scan list of %-bearing rules for that user.
|
||||
// Walked per request; trivially small in practice. Wildcards are NOT u_everyone-
|
||||
// only -- any user can create them.
|
||||
type accessCache struct {
|
||||
exact map[string]map[string]aclEntry
|
||||
pattern map[string][]aclEntry
|
||||
mu sync.RWMutex // Protect exact and pattern
|
||||
}
|
||||
|
||||
// aclEntry mirrors one user_access row. length feeds better()'s "longer
|
||||
// pattern wins" tie-break; the stored topic/pattern string itself is not kept
|
||||
// on the entry (the exact map already keys on it; surfacing wildcard "topics"
|
||||
// like "up%" alongside real ones would invite misuse). pattern is the
|
||||
// compiled regex form of the LIKE pattern; nil for exact entries.
|
||||
type aclEntry struct {
|
||||
length int
|
||||
pattern *regexp.Regexp
|
||||
read bool
|
||||
write bool
|
||||
}
|
||||
|
||||
func newAccessCache() *accessCache {
|
||||
return &accessCache{
|
||||
exact: make(map[string]map[string]aclEntry),
|
||||
pattern: make(map[string][]aclEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the effective (read, write, found) permission for the given
|
||||
// (username, topic), preserving the priority ordering of the original SQL query:
|
||||
// 1. specific user beats Everyone
|
||||
// 2. longer pattern beats shorter (more specific wins)
|
||||
// 3. write beats read at equal length (write is "stronger")
|
||||
func (c *accessCache) Lookup(username, topic string) (read, write, found bool) {
|
||||
escapedTopic := escapeUnderscore(topic)
|
||||
c.mu.RLock()
|
||||
if username != Everyone {
|
||||
if entry, found := c.lookupNoLock(username, topic, escapedTopic); found {
|
||||
c.mu.RUnlock()
|
||||
maybeLogACLDecision(username, username, topic, entry.read, entry.write)
|
||||
return entry.read, entry.write, true
|
||||
}
|
||||
}
|
||||
if entry, found := c.lookupNoLock(Everyone, topic, escapedTopic); found {
|
||||
c.mu.RUnlock()
|
||||
maybeLogACLDecision(username, Everyone, topic, entry.read, entry.write)
|
||||
return entry.read, entry.write, true
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
maybeLogACLDecision(username, "", topic, false, false)
|
||||
return false, false, false
|
||||
}
|
||||
|
||||
// Reload scans (user_name, topic, read, write) rows and merges them into the
|
||||
// cache. With no usernames the cache is replaced wholesale; otherwise the
|
||||
// query is invoked with those usernames as positional args and only the
|
||||
// listed users' slices are touched (a username absent from the result drops
|
||||
// them from both maps). Runs against the primary so a reload after a
|
||||
// mutation sees the just-written rows.
|
||||
func (c *accessCache) Reload(d *db.DB, query string, usernames ...string) error {
|
||||
started := time.Now()
|
||||
scope := "full"
|
||||
if len(usernames) > 0 {
|
||||
scope = "users=" + strings.Join(usernames, ",")
|
||||
}
|
||||
args := make([]any, len(usernames))
|
||||
for i, u := range usernames {
|
||||
args[i] = u
|
||||
}
|
||||
// Query the database for all ACL entries
|
||||
rows, err := d.Query(query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
exacts := make(map[string]map[string]aclEntry)
|
||||
patterns := make(map[string][]aclEntry)
|
||||
updatedEntries := 0
|
||||
for rows.Next() {
|
||||
var username, escapedTopic string
|
||||
var read, write bool
|
||||
if err := rows.Scan(&username, &escapedTopic, &read, &write); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, hasWildcard, err := toACLEntry(escapedTopic, read, write)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasWildcard {
|
||||
patterns[username] = append(patterns[username], entry)
|
||||
} else {
|
||||
if exacts[username] == nil {
|
||||
exacts[username] = make(map[string]aclEntry)
|
||||
}
|
||||
exacts[username][escapedTopic] = entry
|
||||
}
|
||||
updatedEntries++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Replace or update the internal maps
|
||||
c.mu.Lock()
|
||||
if len(usernames) == 0 {
|
||||
c.exact = exacts
|
||||
c.pattern = patterns
|
||||
} else {
|
||||
for _, u := range usernames {
|
||||
if e, ok := exacts[u]; ok {
|
||||
c.exact[u] = e
|
||||
} else {
|
||||
delete(c.exact, u)
|
||||
}
|
||||
if p, ok := patterns[u]; ok {
|
||||
c.pattern[u] = p
|
||||
} else {
|
||||
delete(c.pattern, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
log.Tag(tag).
|
||||
Field("reload_scope", scope).
|
||||
Field("updated_entries", updatedEntries).
|
||||
Field("duration_ms", time.Since(started).Milliseconds()).
|
||||
Debug("Reloaded ACL cache")
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupNoLock returns the highest-priority entry for a single user. When
|
||||
// more than one of that user's rules matches the requested topic, the winner
|
||||
// is chosen by:
|
||||
//
|
||||
// 1. longer stored pattern beats shorter (a more specific rule wins over a
|
||||
// more general one)
|
||||
// 2. at equal length, write beats read (a stronger permission wins the tie)
|
||||
//
|
||||
// Exact and wildcard rules are ranked together under the same criteria, so
|
||||
// an exact "foo" (length 3) beats a wildcard "f%" (length 2), but a wildcard
|
||||
// "foo%" (length 4) beats an exact "foo" (length 3).
|
||||
func (c *accessCache) lookupNoLock(username, topic, escapedTopic string) (*aclEntry, bool) {
|
||||
var best aclEntry
|
||||
var found bool
|
||||
if exact, exists := c.exact[username]; exists {
|
||||
if entry, exists := exact[escapedTopic]; exists {
|
||||
best, found = entry, true
|
||||
}
|
||||
}
|
||||
for _, pattern := range c.pattern[username] {
|
||||
if !pattern.pattern.MatchString(topic) {
|
||||
continue
|
||||
} else if !found || better(pattern, best) {
|
||||
best, found = pattern, true
|
||||
}
|
||||
}
|
||||
return &best, found
|
||||
}
|
||||
|
||||
// toACLEntry builds an aclEntry from one user_access row's values. The
|
||||
// isWildcard return tells the caller which storage slot the entry belongs in:
|
||||
// the per-user wildcard slice if true, the per-user exact map if false.
|
||||
// Wildcards have their LIKE pattern pre-compiled into entry.pattern; exact
|
||||
// entries leave entry.pattern nil.
|
||||
func toACLEntry(escapedTopic string, read, write bool) (entry aclEntry, hasWildcard bool, err error) {
|
||||
entry = aclEntry{
|
||||
length: len(escapedTopic),
|
||||
read: read,
|
||||
write: write,
|
||||
}
|
||||
if !strings.Contains(escapedTopic, "%") {
|
||||
return entry, false, nil
|
||||
}
|
||||
pattern, err := compileLikeToRegex(escapedTopic)
|
||||
if err != nil {
|
||||
return entry, true, err
|
||||
}
|
||||
entry.pattern = pattern
|
||||
return entry, true, nil
|
||||
}
|
||||
|
||||
// better implements the (length DESC, write DESC) tie-break used by the original
|
||||
// query's ORDER BY for entries owned by the same user.
|
||||
func better(a, b aclEntry) bool {
|
||||
if a.length != b.length {
|
||||
return a.length > b.length
|
||||
} else if a.write != b.write {
|
||||
return a.write
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compileLikeToRegex converts a stored ntfy LIKE pattern into an equivalent Go
|
||||
// regexp. In ntfy's stored form, % is the only wildcard (translated from *) and
|
||||
// \_ is a literal underscore; no other backslashes occur. Topics themselves are
|
||||
// restricted to [A-Za-z0-9_-] (see AllowedTopic), so neither % nor stray
|
||||
// backslashes appear in user-supplied input.
|
||||
func compileLikeToRegex(pattern string) (*regexp.Regexp, error) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("^")
|
||||
i := 0
|
||||
for i < len(pattern) {
|
||||
switch {
|
||||
case pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '_':
|
||||
sb.WriteString(regexp.QuoteMeta("_"))
|
||||
i += 2
|
||||
case pattern[i] == '%':
|
||||
sb.WriteString(".*")
|
||||
i++
|
||||
default:
|
||||
sb.WriteString(regexp.QuoteMeta(string(pattern[i])))
|
||||
i++
|
||||
}
|
||||
}
|
||||
sb.WriteString("$")
|
||||
return regexp.Compile(sb.String())
|
||||
}
|
||||
|
||||
// maybeLogACLDecision logs an ACL lookup result
|
||||
func maybeLogACLDecision(requestUser, matchedUser, topic string, read, write bool) {
|
||||
ev := log.Tag(tag).
|
||||
Field("user_name", requestUser).
|
||||
Field("topic", topic).
|
||||
Field("read", read).
|
||||
Field("write", write)
|
||||
if !ev.IsTrace() {
|
||||
return
|
||||
}
|
||||
if matchedUser == "" {
|
||||
ev.Trace("ACL no match")
|
||||
return
|
||||
}
|
||||
ev.Field("matched_user", matchedUser).Trace("ACL match")
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Cache-only unit tests. Integration with the Manager (loading from the DB,
|
||||
// reload-after-mutation, end-to-end Authorize behavior) is covered by the
|
||||
// existing TestStoreAuthorizeTopicAccess* tests in manager_test.go via
|
||||
// forEachStoreBackend.
|
||||
|
||||
func TestCompileLikeToRegex_Exact(t *testing.T) {
|
||||
r := mustCompileLikeToRegex(t, "foo")
|
||||
require.True(t, r.MatchString("foo"))
|
||||
require.False(t, r.MatchString("foox"))
|
||||
require.False(t, r.MatchString("xfoo"))
|
||||
}
|
||||
|
||||
func TestCompileLikeToRegex_TrailingPercent(t *testing.T) {
|
||||
r := mustCompileLikeToRegex(t, "up%")
|
||||
require.True(t, r.MatchString("up"))
|
||||
require.True(t, r.MatchString("up123"))
|
||||
require.False(t, r.MatchString("xup"))
|
||||
}
|
||||
|
||||
func TestCompileLikeToRegex_LeadingAndEmbeddedPercent(t *testing.T) {
|
||||
r := mustCompileLikeToRegex(t, "%test%")
|
||||
require.True(t, r.MatchString("test"))
|
||||
require.True(t, r.MatchString("mytest"))
|
||||
require.True(t, r.MatchString("testxxx"))
|
||||
require.True(t, r.MatchString("xtestx"))
|
||||
require.False(t, r.MatchString("nope"))
|
||||
}
|
||||
|
||||
func TestCompileLikeToRegex_EscapedUnderscore(t *testing.T) {
|
||||
// "my\_topic" is the stored form of a literal "my_topic" -- the underscore
|
||||
// must match itself, NOT act as a SQL one-character wildcard.
|
||||
r := mustCompileLikeToRegex(t, `my\_topic`)
|
||||
require.True(t, r.MatchString("my_topic"))
|
||||
require.False(t, r.MatchString("myXtopic"))
|
||||
require.False(t, r.MatchString("mytopic"))
|
||||
}
|
||||
|
||||
func TestCompileLikeToRegex_EscapedUnderscoreAdjacentToPercent(t *testing.T) {
|
||||
// "nz\_vip\_%" is the stored form of "nz_vip_*" -- literal "nz_vip_" prefix
|
||||
// followed by any suffix.
|
||||
r := mustCompileLikeToRegex(t, `nz\_vip\_%`)
|
||||
require.True(t, r.MatchString("nz_vip_"))
|
||||
require.True(t, r.MatchString("nz_vip_alpha"))
|
||||
require.False(t, r.MatchString("nz_vipX"))
|
||||
require.False(t, r.MatchString("nzvip_alpha"))
|
||||
}
|
||||
|
||||
func TestCompileLikeToRegex_RegexMetaCharsInTopic(t *testing.T) {
|
||||
// Topics in ntfy can include '-', which is benign, but make sure
|
||||
// regex metacharacters in the pattern are escaped properly anyway.
|
||||
r := mustCompileLikeToRegex(t, "foo-bar")
|
||||
require.True(t, r.MatchString("foo-bar"))
|
||||
require.False(t, r.MatchString("foo.bar")) // would match if '-' leaked into a character class
|
||||
}
|
||||
|
||||
func TestACLCache_LookupBeforeReload(t *testing.T) {
|
||||
// A freshly-constructed cache has empty exact and wildcards maps. The
|
||||
// cache treats this as "no rule found", which the caller resolves via
|
||||
// DefaultAccess.
|
||||
c := newAccessCache()
|
||||
read, write, found := c.Lookup("phil", "mytopic")
|
||||
require.False(t, found)
|
||||
require.False(t, read)
|
||||
require.False(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_ExactMatchHit(t *testing.T) {
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: "phil", topic: "mytopic", read: true, write: true},
|
||||
})
|
||||
read, write, found := c.Lookup("phil", "mytopic")
|
||||
require.True(t, found)
|
||||
require.True(t, read)
|
||||
require.True(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_ExactMatchMiss(t *testing.T) {
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: "phil", topic: "mytopic", read: true, write: true},
|
||||
})
|
||||
_, _, found := c.Lookup("phil", "othertopic")
|
||||
require.False(t, found)
|
||||
}
|
||||
|
||||
func TestACLCache_LiteralUnderscoreExactMatch(t *testing.T) {
|
||||
// Stored as "my\_topic" (toSQLWildcard of "my_topic"). A literal underscore
|
||||
// in the requested topic must match, while any other single char must not.
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: "phil", topic: `my\_topic`, read: true, write: false},
|
||||
})
|
||||
read, write, found := c.Lookup("phil", "my_topic")
|
||||
require.True(t, found)
|
||||
require.True(t, read)
|
||||
require.False(t, write)
|
||||
|
||||
_, _, found = c.Lookup("phil", "myXtopic")
|
||||
require.False(t, found)
|
||||
}
|
||||
|
||||
func TestACLCache_WildcardMatch(t *testing.T) {
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: Everyone, topic: "up%", read: false, write: true},
|
||||
})
|
||||
read, write, found := c.Lookup("phil", "up42")
|
||||
require.True(t, found)
|
||||
require.False(t, read)
|
||||
require.True(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) {
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: Everyone, topic: "mytopic", read: true, write: false},
|
||||
{user: "phil", topic: "mytopic", read: false, write: false}, // deny-all for phil
|
||||
})
|
||||
read, write, found := c.Lookup("phil", "mytopic")
|
||||
require.True(t, found)
|
||||
require.False(t, read)
|
||||
require.False(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_SpecificUserBeatsEveryoneEvenWhenShorter(t *testing.T) {
|
||||
// The SQL's "user_name DESC" sort key takes precedence over LENGTH(topic).
|
||||
// Concretely: a specific user with a shorter matching rule still wins over
|
||||
// Everyone with a longer matching rule.
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: Everyone, topic: "foo", read: true, write: true}, // exact, length 3
|
||||
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
|
||||
})
|
||||
read, write, found := c.Lookup("phil", "foo")
|
||||
require.True(t, found)
|
||||
require.False(t, read)
|
||||
require.False(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_SpecificUserBeatsEveryoneRegardlessOfWrite(t *testing.T) {
|
||||
// Same-length rules but conflicting permissions across user boundary: the
|
||||
// specific user always wins, even if its permission set is weaker (or
|
||||
// stronger, in either direction).
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: Everyone, topic: "mytopic", read: true, write: true}, // wide-open
|
||||
{user: "phil", topic: "mytopic", read: true, write: false}, // read-only for phil
|
||||
})
|
||||
read, write, found := c.Lookup("phil", "mytopic")
|
||||
require.True(t, found)
|
||||
require.True(t, read)
|
||||
require.False(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_AnonymousReadsEveryone(t *testing.T) {
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: Everyone, topic: "announcements", read: true, write: false},
|
||||
})
|
||||
read, write, found := c.Lookup(Everyone, "announcements")
|
||||
require.True(t, found)
|
||||
require.True(t, read)
|
||||
require.False(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) {
|
||||
// Both rules belong to the same user (Everyone). The more specific (longer)
|
||||
// "mytopic%" should beat the catch-all "%".
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: Everyone, topic: "%", read: true, write: false},
|
||||
{user: Everyone, topic: "mytopic%", read: true, write: true},
|
||||
})
|
||||
read, write, found := c.Lookup(Everyone, "mytopicX")
|
||||
require.True(t, found)
|
||||
require.True(t, read)
|
||||
require.True(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_ExactBeatsShorterWildcardSameUser(t *testing.T) {
|
||||
// Same user, two matching rules: exact "foo" (length 3) and wildcard "f%"
|
||||
// (length 2). The longer one wins, which is the exact rule -- mirroring
|
||||
// the SQL's "LENGTH(topic) DESC" tie-break. Crucially, the cache must seed
|
||||
// "best" from the exact map probe before walking wildcards, otherwise a
|
||||
// shorter wildcard could overwrite a longer exact.
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: "phil", topic: "foo", read: true, write: true}, // exact, length 3
|
||||
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
|
||||
})
|
||||
read, write, found := c.Lookup("phil", "foo")
|
||||
require.True(t, found)
|
||||
require.True(t, read)
|
||||
require.True(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_LongerWildcardBeatsExactSameUser(t *testing.T) {
|
||||
// Same user, two matching rules: exact "foo" (length 3) and wildcard "foo%"
|
||||
// (length 4). The wildcard wins on length DESC. Exercises the "swap best
|
||||
// to wildcard when better() returns true" path.
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: "phil", topic: "foo", read: false, write: false}, // exact, length 3, deny-all
|
||||
{user: "phil", topic: "foo%", read: true, write: true}, // wildcard, length 4
|
||||
})
|
||||
read, write, found := c.Lookup("phil", "foo")
|
||||
require.True(t, found)
|
||||
require.True(t, read)
|
||||
require.True(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) {
|
||||
// Two wildcard rules of identical length for the same user. The write rule
|
||||
// should win the tie-break. The two-rows-with-same-topic shape is
|
||||
// impossible via real upsert (pkey would conflict), so we inject the entries
|
||||
// directly into the cache's wildcard slice.
|
||||
c := newAccessCache()
|
||||
c.mu.Lock()
|
||||
c.exact = map[string]map[string]aclEntry{}
|
||||
c.pattern = map[string][]aclEntry{
|
||||
Everyone: {
|
||||
{length: len("ab%"), read: true, write: false, pattern: mustCompileLikeToRegex(t, "ab%")},
|
||||
{length: len("ab%"), read: false, write: true, pattern: mustCompileLikeToRegex(t, "ab%")},
|
||||
},
|
||||
}
|
||||
c.mu.Unlock()
|
||||
_, write, found := c.Lookup(Everyone, "abc")
|
||||
require.True(t, found)
|
||||
require.True(t, write)
|
||||
}
|
||||
|
||||
func TestACLCache_ConcurrentLookupAndReload(t *testing.T) {
|
||||
// Lock-based swap must be safe under concurrent reads. The race detector
|
||||
// catches any unsafe shared mutation.
|
||||
c := newAccessCache()
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: Everyone, topic: "mytopic", read: true, write: true},
|
||||
})
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
_, _, _ = c.Lookup(Everyone, "mytopic")
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
loadCache(t, c, []rawACLRow{
|
||||
{user: Everyone, topic: "mytopic", read: i%2 == 0, write: i%2 == 1},
|
||||
})
|
||||
}
|
||||
stop.Store(true)
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// rawACLRow models the rows that reload would Scan from the DB but avoids
|
||||
// actually opening a DB for these unit tests.
|
||||
type rawACLRow struct {
|
||||
user string
|
||||
topic string
|
||||
read bool
|
||||
write bool
|
||||
}
|
||||
|
||||
// loadCache writes the given rows into the cache under its write lock,
|
||||
// preserving the same exact/wildcard partitioning that reload would produce.
|
||||
func loadCache(t *testing.T, c *accessCache, rows []rawACLRow) {
|
||||
t.Helper()
|
||||
exact := make(map[string]map[string]aclEntry)
|
||||
wildcards := make(map[string][]aclEntry)
|
||||
for _, r := range rows {
|
||||
e := aclEntry{length: len(r.topic), read: r.read, write: r.write}
|
||||
if strings.Contains(r.topic, "%") {
|
||||
e.pattern = mustCompileLikeToRegex(t, r.topic)
|
||||
wildcards[r.user] = append(wildcards[r.user], e)
|
||||
} else {
|
||||
if exact[r.user] == nil {
|
||||
exact[r.user] = make(map[string]aclEntry)
|
||||
}
|
||||
exact[r.user][r.topic] = e
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.exact = exact
|
||||
c.pattern = wildcards
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func mustCompileLikeToRegex(t *testing.T, pattern string) *regexp.Regexp {
|
||||
t.Helper()
|
||||
r, err := compileLikeToRegex(pattern)
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
+148
-27
@@ -38,6 +38,8 @@ const (
|
||||
const (
|
||||
DefaultUserStatsQueueWriterInterval = 33 * time.Second
|
||||
DefaultUserPasswordBcryptCost = 10
|
||||
DefaultAccessCacheEnabled = false
|
||||
DefaultAccessCacheReloadInterval = 87 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -48,12 +50,14 @@ var (
|
||||
|
||||
// Manager handles user authentication, authorization, and management
|
||||
type Manager struct {
|
||||
config *Config
|
||||
db *db.DB
|
||||
queries queries
|
||||
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
|
||||
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
|
||||
mu sync.Mutex
|
||||
config *Config
|
||||
db *db.DB
|
||||
queries queries
|
||||
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
|
||||
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
|
||||
accessCache *accessCache // In-memory snapshot of user_access; refreshed by maybeReloadAccessCache after every ACL mutation
|
||||
quit chan struct{} // Closed by Close() to signal background goroutines to stop
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ Auther = (*Manager)(nil)
|
||||
@@ -65,20 +69,65 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
||||
if config.QueueWriterInterval.Seconds() <= 0 {
|
||||
config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval
|
||||
}
|
||||
if config.AccessCacheReloadInterval <= 0 {
|
||||
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
|
||||
}
|
||||
manager := &Manager{
|
||||
config: config,
|
||||
db: d,
|
||||
statsQueue: make(map[string]*Stats),
|
||||
tokenQueue: make(map[string]*TokenUpdate),
|
||||
quit: make(chan struct{}),
|
||||
queries: queries,
|
||||
}
|
||||
if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go manager.asyncQueueWriter(manager.config.QueueWriterInterval)
|
||||
if config.AccessCacheEnabled {
|
||||
manager.accessCache = newAccessCache()
|
||||
if err := manager.maybeReloadAccessCache(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
|
||||
}
|
||||
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// maybeReloadAccessCache refreshes the in-memory access cache from the
|
||||
// primary database. No-op when the cache is disabled. With no usernames it
|
||||
// does a full bulk reload; with one or more it refreshes only those users'
|
||||
// slices in a single DB round-trip via an IN clause.
|
||||
func (a *Manager) maybeReloadAccessCache(usernames ...string) error {
|
||||
if a.accessCache == nil {
|
||||
return nil
|
||||
}
|
||||
if len(usernames) == 0 {
|
||||
return a.accessCache.Reload(a.db, a.queries.selectAccessCacheAll)
|
||||
}
|
||||
return a.accessCache.Reload(a.db, a.queries.selectAccessCacheUsers(len(usernames)), usernames...)
|
||||
}
|
||||
|
||||
// asyncAccessCacheReloadLoop periodically bulk-reloads the access cache so that
|
||||
// writes made by other processes against the same database (most notably the
|
||||
// `ntfy access` CLI subcommand running while a server holds the cache) become
|
||||
// visible within the configured interval. This Manager's own mutations do
|
||||
// not depend on the poller -- they refresh affected users synchronously.
|
||||
func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.quit:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := a.maybeReloadAccessCache(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Reloading ACL cache failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate checks username and password and returns a User if correct, and the user has not been
|
||||
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
|
||||
// the password is correct or incorrect.
|
||||
@@ -151,9 +200,14 @@ func (a *Manager) RemoveUser(username string) error {
|
||||
if err := a.CanChangeUser(username); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
return a.removeUserTx(tx, username)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Reload user-specific parts of the access cache
|
||||
return a.maybeReloadAccessCache(username, Everyone)
|
||||
}
|
||||
|
||||
// removeUserTx deletes the user with the given username
|
||||
@@ -174,7 +228,7 @@ func (a *Manager) MarkUserRemoved(user *User) error {
|
||||
if !AllowedUsername(user.Name) {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
if err := a.resetUserAccessTx(tx, user.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -187,14 +241,27 @@ func (a *Manager) MarkUserRemoved(user *User) error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Reload user-specific parts of the access cache
|
||||
return a.maybeReloadAccessCache(user.Name, Everyone)
|
||||
}
|
||||
|
||||
// RemoveDeletedUsers deletes all users that have been marked deleted
|
||||
func (a *Manager) RemoveDeletedUsers() error {
|
||||
if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil {
|
||||
res, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if affected == 0 {
|
||||
return nil
|
||||
}
|
||||
// Full cache reload, because we don't know which users were affected.
|
||||
return a.maybeReloadAccessCache()
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password
|
||||
@@ -225,9 +292,14 @@ func (a *Manager) ChangeRole(username string, role Role) error {
|
||||
if err := a.CanChangeUser(username); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
return a.changeRoleTx(tx, username, role)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Full cache reload: Role changes are extremely rare.
|
||||
return a.maybeReloadAccessCache()
|
||||
}
|
||||
|
||||
// changeRoleTx changes a user's role
|
||||
@@ -351,14 +423,20 @@ func (a *Manager) EnqueueUserStats(userID string, stats *Stats) {
|
||||
a.statsQueue[userID] = stats
|
||||
}
|
||||
|
||||
func (a *Manager) asyncQueueWriter(interval time.Duration) {
|
||||
func (a *Manager) asyncQueueWriteLoop(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
for range ticker.C {
|
||||
if err := a.writeUserStatsQueue(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
|
||||
}
|
||||
if err := a.writeTokenUpdateQueue(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Writing token update queue failed")
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.quit:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := a.writeUserStatsQueue(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
|
||||
}
|
||||
if err := a.writeTokenUpdateQueue(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Writing token update queue failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,9 +666,14 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
|
||||
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
|
||||
// owner may either be a user (username), or the system (empty).
|
||||
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
return a.allowAccessTx(tx, username, topicPattern, permission, false)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only this user's row set changed; refresh their slice only.
|
||||
return a.maybeReloadAccessCache(username)
|
||||
}
|
||||
|
||||
func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {
|
||||
@@ -606,9 +689,20 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string
|
||||
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
|
||||
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
|
||||
func (a *Manager) ResetAccess(username string, topicPattern string) error {
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
return a.resetAccessTx(tx, username, topicPattern)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Empty username -> deleteAllAccess affected every user, bulk reload.
|
||||
// Otherwise refresh the named user plus Everyone, since resetUserAccessTx
|
||||
// and deleteTopicAccess both touch rows owned by the user (typically the
|
||||
// Everyone row from their reservations).
|
||||
if username == "" {
|
||||
return a.maybeReloadAccessCache()
|
||||
}
|
||||
return a.maybeReloadAccessCache(username, Everyone)
|
||||
}
|
||||
|
||||
func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error {
|
||||
@@ -650,10 +744,20 @@ func (a *Manager) AllowReservation(username string, topic string) error {
|
||||
// authorizeTopicAccess returns the read/write permissions for the given username and topic.
|
||||
// The found return value indicates whether an ACL entry was found at all.
|
||||
//
|
||||
// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user.
|
||||
// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*"
|
||||
// - It also prioritizes write permissions over read permissions
|
||||
// Priority:
|
||||
// - Specific user beats Everyone
|
||||
// - Longer pattern beats shorter (a more specific rule beats a more general one,
|
||||
// e.g. "test*" > "*")
|
||||
// - Write beats read at equal length
|
||||
//
|
||||
// When AccessCacheEnabled is true (config), the lookup is served entirely from
|
||||
// the in-memory snapshot maintained by accessCache. Otherwise the original SQL
|
||||
// query is executed against the database on every call.
|
||||
func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) {
|
||||
if a.accessCache != nil {
|
||||
read, write, found = a.accessCache.Lookup(usernameOrEveryone, topic)
|
||||
return read, write, found, nil
|
||||
}
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)
|
||||
if err != nil {
|
||||
return false, false, false, err
|
||||
@@ -731,7 +835,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
|
||||
if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
if limit > 0 {
|
||||
hasReservation, err := a.hasReservationTx(tx, username, topic)
|
||||
if err != nil {
|
||||
@@ -755,6 +859,11 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Both user's and Everyone's rows changed.
|
||||
return a.maybeReloadAccessCache(username, Everyone)
|
||||
}
|
||||
|
||||
// RemoveReservations deletes the access control entries associated with the given username/topic,
|
||||
@@ -769,7 +878,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
}
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
for _, topic := range topics {
|
||||
if err := a.removeReservationAccessTx(tx, username, topic); err != nil {
|
||||
return err
|
||||
@@ -777,6 +886,12 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Mirror the DB: rows for this user and any Everyone rows owned by this
|
||||
// user are gone. Refresh both slices.
|
||||
return a.maybeReloadAccessCache(username, Everyone)
|
||||
}
|
||||
|
||||
// Reservations returns all user-owned topics, and the associated everyone-access
|
||||
@@ -1515,8 +1630,14 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database
|
||||
// Close stops background goroutines and closes the underlying database.
|
||||
func (a *Manager) Close() error {
|
||||
select {
|
||||
case <-a.quit:
|
||||
// Already closed
|
||||
default:
|
||||
close(a.quit)
|
||||
}
|
||||
return a.db.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
)
|
||||
|
||||
@@ -77,6 +80,11 @@ const (
|
||||
WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\'
|
||||
ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC
|
||||
`
|
||||
postgresSelectAccessCacheAllQuery = `
|
||||
SELECT u.user_name, a.topic, a.read, a.write
|
||||
FROM user_access a
|
||||
JOIN "user" u ON u.id = a.user_id
|
||||
`
|
||||
postgresSelectUserAllAccessQuery = `
|
||||
SELECT user_id, topic, read, write, provisioned
|
||||
FROM user_access
|
||||
@@ -221,6 +229,21 @@ const (
|
||||
`
|
||||
)
|
||||
|
||||
// postgresSelectAccessCacheUsersQuery builds the per-users cache-load query
|
||||
// with a "$1, $2, ..." IN clause sized for n usernames.
|
||||
func postgresSelectAccessCacheUsersQuery(n int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`SELECT u.user_name, a.topic, a.read, a.write FROM user_access a JOIN "user" u ON u.id = a.user_id WHERE u.user_name IN (`)
|
||||
for i := 0; i < n; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, "$%d", i+1)
|
||||
}
|
||||
sb.WriteString(")")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
|
||||
var postgresQueries = queries{
|
||||
selectUserByID: postgresSelectUserByIDQuery,
|
||||
@@ -245,6 +268,8 @@ var postgresQueries = queries{
|
||||
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: postgresSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: postgresSelectUserAllAccessQuery,
|
||||
selectUserAccess: postgresSelectUserAccessQuery,
|
||||
selectUserReservations: postgresSelectUserReservationsQuery,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
|
||||
@@ -83,6 +84,11 @@ const (
|
||||
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\'
|
||||
ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC
|
||||
`
|
||||
sqliteSelectAccessCacheAllQuery = `
|
||||
SELECT u.user, a.topic, a.read, a.write
|
||||
FROM user_access a
|
||||
JOIN user u ON u.id = a.user_id
|
||||
`
|
||||
sqliteSelectUserAllAccessQuery = `
|
||||
SELECT user_id, topic, read, write, provisioned
|
||||
FROM user_access
|
||||
@@ -220,6 +226,21 @@ const (
|
||||
`
|
||||
)
|
||||
|
||||
// sqliteSelectAccessCacheUsersQuery builds the per-users cache-load query
|
||||
// with a "?, ?, ..." IN clause sized for n usernames.
|
||||
func sqliteSelectAccessCacheUsersQuery(n int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id WHERE u.user IN (`)
|
||||
for i := 0; i < n; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("?")
|
||||
}
|
||||
sb.WriteString(")")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var sqliteQueries = queries{
|
||||
selectUserByID: sqliteSelectUserByIDQuery,
|
||||
selectUserByName: sqliteSelectUserByNameQuery,
|
||||
@@ -243,6 +264,8 @@ var sqliteQueries = queries{
|
||||
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: sqliteSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
|
||||
selectUserAccess: sqliteSelectUserAccessQuery,
|
||||
selectUserReservations: sqliteSelectUserReservationsQuery,
|
||||
|
||||
@@ -2169,6 +2169,148 @@ func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestAuthorizeTopicAccess_CacheAndDirectDBAgree wires up two Managers on the
|
||||
// same backend storage -- one with AccessCacheEnabled=true (in-memory cache
|
||||
// path) and one with AccessCacheEnabled=false (direct SQL path) -- then runs
|
||||
// an identical battery of authorizeTopicAccess queries against both and
|
||||
// asserts byte-identical (read, write, found) responses for every query.
|
||||
// This protects the in-memory implementation from drifting away from the
|
||||
// SQL behavior it is meant to mirror.
|
||||
func TestAuthorizeTopicAccess_CacheAndDirectDBAgree(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
// Seed via a Manager with the cache enabled. Writes go to the shared
|
||||
// backend; both Managers will see them after the writes commit.
|
||||
writer := newManager(&Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
BcryptCost: bcrypt.MinCost,
|
||||
AccessCacheEnabled: true,
|
||||
})
|
||||
t.Cleanup(func() { writer.Close() })
|
||||
|
||||
require.Nil(t, writer.AddUser("phil", "mypass", RoleAdmin, false))
|
||||
require.Nil(t, writer.AddUser("ben", "mypass", RoleUser, false))
|
||||
require.Nil(t, writer.AddUser("alice", "mypass", RoleUser, false))
|
||||
|
||||
// A mix that exercises every branch of the priority logic:
|
||||
// - exact and wildcard rules for the same user
|
||||
// - exact and wildcard rules under Everyone
|
||||
// - Everyone rules that are longer than the matching user rule
|
||||
// - literal underscores (stored as "\_")
|
||||
// - deny-all permissions
|
||||
require.Nil(t, writer.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||
require.Nil(t, writer.AllowAccess("ben", "readme", PermissionRead))
|
||||
require.Nil(t, writer.AllowAccess("ben", "writeme", PermissionWrite))
|
||||
require.Nil(t, writer.AllowAccess("ben", "ben_topic", PermissionReadWrite))
|
||||
require.Nil(t, writer.AllowAccess("ben", "mytopic*", PermissionRead))
|
||||
require.Nil(t, writer.AllowAccess("alice", "alice_*", PermissionWrite))
|
||||
require.Nil(t, writer.AllowAccess("alice", "secret", PermissionDenyAll))
|
||||
require.Nil(t, writer.AllowAccess(Everyone, "announcements", PermissionRead))
|
||||
require.Nil(t, writer.AllowAccess(Everyone, "up*", PermissionWrite))
|
||||
require.Nil(t, writer.AllowAccess(Everyone, "mytopic", PermissionDenyAll))
|
||||
|
||||
// Build a reader Manager with the cache OFF, pointing at the same backend.
|
||||
reader := newManager(&Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
BcryptCost: bcrypt.MinCost,
|
||||
AccessCacheEnabled: false,
|
||||
})
|
||||
t.Cleanup(func() { reader.Close() })
|
||||
|
||||
// Probe matrix: every (user, topic) pair that exercises some branch.
|
||||
cases := []struct {
|
||||
user, topic string
|
||||
}{
|
||||
// Anonymous reads.
|
||||
{Everyone, "announcements"},
|
||||
{Everyone, "up42"},
|
||||
{Everyone, "up"},
|
||||
{Everyone, "downstream"},
|
||||
{Everyone, "mytopic"},
|
||||
{Everyone, "nope"},
|
||||
// Specific user, only-user rules.
|
||||
{"ben", "mytopic"},
|
||||
{"ben", "readme"},
|
||||
{"ben", "writeme"},
|
||||
{"ben", "ben_topic"},
|
||||
{"ben", "benXtopic"}, // underscore in rule means "X" must NOT match
|
||||
// Specific user falls through to Everyone.
|
||||
{"ben", "announcements"},
|
||||
{"ben", "up5"},
|
||||
{"alice", "announcements"},
|
||||
// Wildcards with literal underscores.
|
||||
{"alice", "alice_anything"},
|
||||
{"alice", "alice_"},
|
||||
{"alice", "aliceX"}, // does NOT match alice_*
|
||||
// Exact-vs-wildcard overlap for the same user (ben has both
|
||||
// "mytopic" exact and "mytopic*" wildcard).
|
||||
{"ben", "mytopic"}, // exact wins on length
|
||||
{"ben", "mytopicX"}, // only wildcard matches
|
||||
{"ben", "mytopicYZ"}, // only wildcard matches
|
||||
// Deny-all override.
|
||||
{"alice", "secret"},
|
||||
// No matching rule anywhere.
|
||||
{"ben", "completely_unmatched"},
|
||||
{"alice", "completely_unmatched"},
|
||||
{Everyone, "completely_unmatched"},
|
||||
}
|
||||
|
||||
// Sanity: the two Managers must agree on every probe.
|
||||
for _, tc := range cases {
|
||||
cRead, cWrite, cFound, cErr := writer.authorizeTopicAccess(tc.user, tc.topic)
|
||||
dRead, dWrite, dFound, dErr := reader.authorizeTopicAccess(tc.user, tc.topic)
|
||||
require.Nil(t, cErr, "cache path errored for (%s, %s)", tc.user, tc.topic)
|
||||
require.Nil(t, dErr, "direct-DB path errored for (%s, %s)", tc.user, tc.topic)
|
||||
require.Equal(t, dFound, cFound, "found mismatch for (%s, %s)", tc.user, tc.topic)
|
||||
require.Equal(t, dRead, cRead, "read mismatch for (%s, %s)", tc.user, tc.topic)
|
||||
require.Equal(t, dWrite, cWrite, "write mismatch for (%s, %s)", tc.user, tc.topic)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccessCacheReloadInterval_PicksUpExternalWrite proves that the
|
||||
// background reloader actually closes the cross-process coherence gap: a
|
||||
// write made through a *different* Manager on the same backend becomes
|
||||
// visible to a cache-enabled Manager within roughly one reload interval,
|
||||
// without that Manager being told about the write.
|
||||
func TestAccessCacheReloadInterval_PicksUpExternalWrite(t *testing.T) {
|
||||
const interval = 25 * time.Millisecond
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
// reader holds the cache and polls; writer plays the role of an
|
||||
// out-of-band process (e.g. `ntfy access` CLI) writing to the same
|
||||
// backend.
|
||||
reader := newManager(&Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
BcryptCost: bcrypt.MinCost,
|
||||
AccessCacheEnabled: true,
|
||||
AccessCacheReloadInterval: interval,
|
||||
})
|
||||
t.Cleanup(func() { reader.Close() })
|
||||
|
||||
writer := newManager(&Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
BcryptCost: bcrypt.MinCost,
|
||||
AccessCacheEnabled: false,
|
||||
})
|
||||
t.Cleanup(func() { writer.Close() })
|
||||
|
||||
require.Nil(t, writer.AddUser("phil", "mypass", RoleUser, false))
|
||||
// Sanity: before the write, the reader sees no rule for this topic.
|
||||
_, _, found, err := reader.authorizeTopicAccess("phil", "via-poller")
|
||||
require.Nil(t, err)
|
||||
require.False(t, found)
|
||||
|
||||
// Write through the second Manager. reader's cache is unaware.
|
||||
require.Nil(t, writer.AllowAccess("phil", "via-poller", PermissionReadWrite))
|
||||
|
||||
// Wait for the poller to catch up. The interval is 25ms; allow a
|
||||
// generous multiple to keep this test from flaking on slow CI.
|
||||
require.Eventually(t, func() bool {
|
||||
read, write, found, err := reader.authorizeTopicAccess("phil", "via-poller")
|
||||
return err == nil && found && read && write
|
||||
}, 2*time.Second, 10*time.Millisecond, "reader's cache never observed the external write")
|
||||
})
|
||||
}
|
||||
|
||||
func TestStoreReservations(t *testing.T) {
|
||||
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
|
||||
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
|
||||
|
||||
+15
-11
@@ -245,16 +245,18 @@ const (
|
||||
|
||||
// Config holds the configuration for the user Manager
|
||||
type Config struct {
|
||||
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
|
||||
DatabaseURL string // Database connection string (PostgreSQL)
|
||||
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
|
||||
DefaultAccess Permission // Default permission if no ACL matches
|
||||
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
||||
Users []*User // Predefined users to create on startup
|
||||
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
|
||||
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
|
||||
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
|
||||
BcryptCost int // Cost of generated passwords; lowering makes testing faster
|
||||
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
|
||||
DatabaseURL string // Database connection string (PostgreSQL)
|
||||
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
|
||||
DefaultAccess Permission // Default permission if no ACL matches
|
||||
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
||||
Users []*User // Predefined users to create on startup
|
||||
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
|
||||
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
|
||||
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
|
||||
BcryptCost int // Cost of generated passwords; lowering makes testing faster
|
||||
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
||||
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
|
||||
}
|
||||
|
||||
// Error constants used by the package
|
||||
@@ -303,7 +305,9 @@ type queries struct {
|
||||
deleteUsersProvisioned string
|
||||
|
||||
// Access queries
|
||||
selectTopicPerms string
|
||||
selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled
|
||||
selectAccessCacheAll string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache
|
||||
selectAccessCacheUsers func(n int) string // Returns a per-users load query whose IN clause is sized for n usernames
|
||||
selectUserAllAccess string
|
||||
selectUserAccess string
|
||||
selectUserReservations string
|
||||
|
||||
Generated
+514
-623
File diff suppressed because it is too large
Load Diff
+2
-6
@@ -18,19 +18,15 @@
|
||||
"@mui/material": "latest",
|
||||
"dexie": "^3.2.1",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"humanize-duration": "^3.27.3",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"i18next-http-backend": "^1.4.0",
|
||||
"js-base64": "^3.7.2",
|
||||
"i18next-http-backend": "^3.0.5",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-i18next": "^11.16.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"stacktrace-gps": "^3.0.4",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"stylis": "^4.3.0",
|
||||
"stylis-plugin-rtl": "^2.1.1"
|
||||
},
|
||||
@@ -44,7 +40,7 @@
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^2.8.8",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^6.4.2",
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -12,5 +12,33 @@
|
||||
"signup_form_username": "Nom d'usuari",
|
||||
"signup_form_password": "Contrasenya",
|
||||
"signup_form_confirm_password": "Confirma la contrasenya",
|
||||
"signup_form_button_submit": "Dona't d'alta"
|
||||
"signup_form_button_submit": "Dona't d'alta",
|
||||
"signup_form_toggle_password_visibility": "Canvia la visibilitat de la contrasenya",
|
||||
"signup_already_have_account": "Ja tens un compte? Inicia sessió!",
|
||||
"signup_disabled": "Les inscripcions estan deshabilitades",
|
||||
"signup_error_username_taken": "El nom d'usuari {{username}} ja està en ús",
|
||||
"signup_error_creation_limit_reached": "Límit de creació de comptes assolit",
|
||||
"login_title": "Inicia sessió al teu compte ntfy",
|
||||
"login_form_button_submit": "Iniciar sessió",
|
||||
"login_link_signup": "Crear compte",
|
||||
"login_disabled": "L'accès està desactivat",
|
||||
"action_bar_show_menu": "Mostrar el menú",
|
||||
"action_bar_logo_alt": "logotip de ntfy",
|
||||
"action_bar_change_display_name": "Canviar nom de pantalla",
|
||||
"action_bar_reservation_add": "Reservar tema",
|
||||
"action_bar_reservation_edit": "Canviar la reserva",
|
||||
"action_bar_reservation_delete": "Eliminar la reserva",
|
||||
"action_bar_reservation_limit_reached": "Límit assolit",
|
||||
"action_bar_send_test_notification": "Enviar notificació de prova",
|
||||
"action_bar_clear_notifications": "Esborrar totes les notificacions",
|
||||
"action_bar_mute_notifications": "Silenciar notificacions",
|
||||
"action_bar_unmute_notifications": "Reactivar notificacions",
|
||||
"action_bar_unsubscribe": "Cancel·lar la subscripció",
|
||||
"action_bar_toggle_mute": "Silenciar/reactivar notificacions",
|
||||
"action_bar_toggle_action_menu": "Obrir/tancar el menú d'accions",
|
||||
"action_bar_profile_settings": "Configuracions",
|
||||
"action_bar_profile_logout": "Tancar sessió",
|
||||
"action_bar_sign_in": "Iniciar sessió",
|
||||
"action_bar_sign_up": "Crear compte",
|
||||
"message_bar_type_message": "Escriu un missatge aquí"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"common_cancel": "Cancel",
|
||||
"common_save": "Save",
|
||||
"common_add": "Add",
|
||||
"common_back": "Back"
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
"publish_dialog_topic_placeholder": "Nombre del tópico, ej. phil_alerts",
|
||||
"publish_dialog_title_label": "Título",
|
||||
"publish_dialog_message_label": "Mensaje",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, por ejemplo: warning, srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, ej.: aviso, srv1-backup",
|
||||
"publish_dialog_click_label": "Click URL",
|
||||
"publish_dialog_click_placeholder": "URL que se abre cuando se hace click en la notificación",
|
||||
"publish_dialog_email_label": "Email",
|
||||
@@ -120,7 +120,7 @@
|
||||
"publish_dialog_priority_low": "Prioridad baja",
|
||||
"publish_dialog_priority_high": "Prioridad alta",
|
||||
"publish_dialog_delay_label": "Retraso",
|
||||
"publish_dialog_title_placeholder": "Título de la notificación, ej. Alerta de espacio en disco",
|
||||
"publish_dialog_title_placeholder": "Título de la notificación, ej. \"Alerta de espacio en disco\"",
|
||||
"publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.",
|
||||
"publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_placeholder": "Nombre del archivo adjunto",
|
||||
@@ -153,7 +153,7 @@
|
||||
"priority_low": "baja",
|
||||
"notifications_actions_not_supported": "Acción no soportada en la aplicación web",
|
||||
"notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}",
|
||||
"error_boundary_unsupported_indexeddb_description": "La aplicación web ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada. <br/> <br/> Si bien esto es desafortunado, tampoco tiene mucho sentido usar la aplicación web ntfy en modo de navegación privada de todos modos, porque todo está almacenado en el almacenamiento del navegador. Puede leer más sobre esto <githubLink>en este issue de GitHub</githubLink>, o hablar con nosotros en <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_unsupported_indexeddb_description": "La aplicación web de ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada.<br/> <br/>Mismo que no sea ideal, tampoco tiene mucho sentido usar la aplicación web de ntfy en modo de navegación privada de todos modos, porque todo está guardado en el almacenamiento del navegador. Puede leer más sobre esto <githubLink>en este issue de GitHub</githubLink>, o hablar con nosotros en <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
|
||||
"action_bar_show_menu": "Mostrar menú",
|
||||
"action_bar_logo_alt": "logo de ntfy",
|
||||
"action_bar_toggle_action_menu": "Abrir/cerrar el menú de acción",
|
||||
@@ -207,7 +207,7 @@
|
||||
"action_bar_account": "Cuenta",
|
||||
"action_bar_change_display_name": "Cambiar nombre de usuario",
|
||||
"action_bar_reservation_add": "Reservar tema",
|
||||
"action_bar_reservation_edit": "Modificar reserva",
|
||||
"action_bar_reservation_edit": "Alterar la reserva",
|
||||
"action_bar_reservation_delete": "Quitar reserva",
|
||||
"action_bar_reservation_limit_reached": "Límite alcanzado",
|
||||
"action_bar_profile_logout": "Cerrar sesión",
|
||||
|
||||
@@ -307,7 +307,7 @@
|
||||
"account_delete_dialog_label": "Password",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Nessun argomento riservato",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} messaggi giornalieri",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Il livello selezionato consente meno argomenti riservati rispetto al livello corrente. Prima di cambiare il livello, <strong> si prega di eliminare almeno una prenotazione</strong>. È possibile rimuovere le prenotazioni nel <Link>Impostazioni</Link>.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Il livello selezionato consente meno argomenti riservati rispetto al livello corrente. Prima di cambiare il livello, <strong>si prega di eliminare almeno una prenotazione</strong>. È possibile rimuovere le prenotazioni nel <Link>Impostazioni</Link>.",
|
||||
"alert_notification_permission_denied_title": "Le notifiche sono bloccate",
|
||||
"alert_notification_permission_denied_description": "Per favore riabilitale nel tuo browser",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Le notifiche dagli altri server non saranno ricevute quando la web app non è in esecuzione",
|
||||
@@ -403,5 +403,7 @@
|
||||
"web_push_subscription_expiring_body": "Apri ntfy per continuare a ricevere notifiche",
|
||||
"web_push_unknown_notification_title": "Notifica sconosciuta ricevuta dal server",
|
||||
"account_tokens_dialog_expires_x_hours": "Il token scade tra {{hours}} ore",
|
||||
"prefs_reservations_table": "Tabella argomenti riservati"
|
||||
"prefs_reservations_table": "Tabella argomenti riservati",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Un utente autorizzato non può essere modificato o eliminato",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossibile modificare o eliminare il token fornito"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"common_cancel": "Atcelt",
|
||||
"common_save": "Saglabāt",
|
||||
"common_add": "Pievienot",
|
||||
"common_back": "Atpakaļ",
|
||||
"signup_form_username": "Lietotājvārds",
|
||||
"signup_form_password": "Parole",
|
||||
"action_bar_settings": "Iestatījumi",
|
||||
"action_bar_account": "Konts",
|
||||
"action_bar_profile_title": "Profils",
|
||||
"action_bar_profile_settings": "Iestatījumi",
|
||||
"action_bar_profile_logout": "Iziet",
|
||||
"nav_button_account": "Konts",
|
||||
"nav_button_settings": "Iestatījumi",
|
||||
"nav_button_documentation": "Dokumentācija",
|
||||
"nav_button_connecting": "savienojas",
|
||||
"notifications_list_item": "Paziņojums",
|
||||
"notifications_delete": "Dzēst",
|
||||
"notifications_tags": "Birkas",
|
||||
"notifications_example": "Piemērs",
|
||||
"publish_dialog_title_label": "Virsraksts",
|
||||
"publish_dialog_message_label": "Ziņojums",
|
||||
"publish_dialog_tags_label": "Birkas",
|
||||
"publish_dialog_priority_label": "Prioritāte",
|
||||
"publish_dialog_email_label": "E-pasta adrese",
|
||||
"publish_dialog_filename_label": "Datnes nosaukums",
|
||||
"publish_dialog_delay_label": "Aizkave",
|
||||
"publish_dialog_button_cancel": "Atcelt",
|
||||
"publish_dialog_button_send": "Sūtīt",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Atcelt",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Abonēt",
|
||||
"subscribe_dialog_login_password_label": "Parole",
|
||||
"subscribe_dialog_error_user_anonymous": "anonīms lietotājs",
|
||||
"account_basics_title": "Konts",
|
||||
"account_basics_username_title": "Lietotājvārds",
|
||||
"account_basics_password_title": "Parole",
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "Nosūtīt īsziņu",
|
||||
"account_basics_phone_numbers_dialog_channel_call": "Zvanīt",
|
||||
"account_usage_title": "Lietojums",
|
||||
"account_usage_unlimited": "Neierobežots",
|
||||
"account_basics_tier_admin": "Administrators",
|
||||
"account_basics_tier_basic": "Pamata",
|
||||
"account_basics_tier_free": "Bezmaksas",
|
||||
"account_basics_tier_interval_monthly": "ikmēnesi",
|
||||
"account_basics_tier_interval_yearly": "katru gadu",
|
||||
"account_basics_tier_change_button": "Mainīt",
|
||||
"account_delete_dialog_label": "Parole",
|
||||
"account_delete_dialog_button_cancel": "Atcelt",
|
||||
"account_upgrade_dialog_interval_monthly": "Ikmēnesi",
|
||||
"account_upgrade_dialog_interval_yearly": "Katru gadu",
|
||||
"account_upgrade_dialog_tier_price_per_month": "mēnesī",
|
||||
"account_upgrade_dialog_tier_selected_label": "Atlasīts",
|
||||
"account_upgrade_dialog_tier_current_label": "Pašreizējais",
|
||||
"account_upgrade_dialog_button_cancel": "Atcelt",
|
||||
"account_tokens_table_token_header": "Pilnvara",
|
||||
"account_tokens_table_expires_header": "Derīgs līdz",
|
||||
"account_tokens_dialog_button_cancel": "Atcelt",
|
||||
"prefs_notifications_title": "Paziņojumi",
|
||||
"prefs_notifications_delete_after_never": "Nekad",
|
||||
"prefs_notifications_web_push_disabled": "Atspējots",
|
||||
"prefs_users_table_user_header": "Lietotājs",
|
||||
"prefs_users_dialog_password_label": "Parole",
|
||||
"prefs_appearance_title": "Izskats",
|
||||
"prefs_appearance_language_title": "Valoda",
|
||||
"prefs_appearance_theme_title": "Motīvs",
|
||||
"prefs_reservations_table_topic_header": "Tēma",
|
||||
"prefs_reservations_table_access_header": "Piekļuve",
|
||||
"prefs_reservations_dialog_topic_label": "Tēma",
|
||||
"prefs_reservations_dialog_access_label": "Piekļuve",
|
||||
"priority_min": "minimālā",
|
||||
"priority_low": "zema",
|
||||
"priority_default": "noklusējuma",
|
||||
"priority_high": "augsta",
|
||||
"priority_max": "maksimālā",
|
||||
"signup_form_confirm_password": "Atkārtot paroli",
|
||||
"signup_form_button_submit": "Izveidot kontu",
|
||||
"login_link_signup": "Izveidot kontu",
|
||||
"action_bar_show_menu": "Rādīt izvēlni",
|
||||
"action_bar_logo_alt": "ntfy logotips",
|
||||
"action_bar_reservation_add": "Rezervēt tēmu",
|
||||
"action_bar_reservation_edit": "Mainīt rezervāciju",
|
||||
"action_bar_reservation_delete": "Noņemt rezervāciju",
|
||||
"action_bar_reservation_limit_reached": "Sasniegts limits",
|
||||
"action_bar_mute_notifications": "Apklusināt paziņojumus",
|
||||
"action_bar_sign_up": "Izveidot kontu",
|
||||
"message_bar_publish": "Publicēt ziņojumu",
|
||||
"nav_topics_title": "Abonētās tēmas",
|
||||
"nav_button_all_notifications": "Visi paziņojumi",
|
||||
"nav_button_publish_message": "Publicēt paziņojumu",
|
||||
"nav_button_muted": "Paziņojumi apklusināti",
|
||||
"alert_notification_permission_required_button": "Dot tagad",
|
||||
"notifications_list": "Paziņojumu saraksts",
|
||||
"notifications_priority_x": "{{priority}} prioritāte",
|
||||
"notifications_new_indicator": "Jauns paziņojums",
|
||||
"notifications_attachment_image": "Pielikuma attēls",
|
||||
"notifications_attachment_copy_url_button": "Kopēt URL adresi",
|
||||
"notifications_attachment_open_button": "Atvērt pielikumu",
|
||||
"notifications_attachment_file_image": "attēla datne",
|
||||
"notifications_attachment_file_video": "video datne",
|
||||
"notifications_attachment_file_audio": "audio datne",
|
||||
"notifications_attachment_file_document": "cits datnes tips",
|
||||
"notifications_click_copy_url_button": "Kopēt saiti",
|
||||
"notifications_click_open_button": "Atvērt saiti",
|
||||
"notifications_actions_failed_notification": "Neveiksmīga darbība",
|
||||
"publish_dialog_title_no_topic": "Publicēt paziņojumu",
|
||||
"publish_dialog_progress_uploading": "Augšupielādē …",
|
||||
"publish_dialog_message_published": "Paziņojums publicēts",
|
||||
"publish_dialog_emoji_picker_show": "Atlasīt emocijzīmi",
|
||||
"publish_dialog_priority_min": "Minimāla prioritāte",
|
||||
"publish_dialog_priority_low": "Zema prioritāte",
|
||||
"publish_dialog_priority_default": "Noklusējuma prioritāte",
|
||||
"publish_dialog_priority_high": "Augsta prioritāte",
|
||||
"publish_dialog_priority_max": "Maksimāla prioritāte",
|
||||
"publish_dialog_base_url_label": "Pakalpojuma URL adrese",
|
||||
"publish_dialog_topic_label": "Tēmas nosaukums",
|
||||
"publish_dialog_topic_reset": "Atiestatīt tēmu",
|
||||
"publish_dialog_click_label": "Klikšķināma URL adrese",
|
||||
"publish_dialog_call_label": "Tālruņa zvans",
|
||||
"publish_dialog_attach_label": "Pielikuma URL adrese",
|
||||
"publish_dialog_filename_placeholder": "Pielikuma datnes nosaukums",
|
||||
"publish_dialog_other_features": "Citas funkcijas:",
|
||||
"publish_dialog_chip_call_label": "Tālruņa zvans",
|
||||
"publish_dialog_chip_delay_label": "Aizkavēt piegādi",
|
||||
"publish_dialog_chip_topic_label": "Mainīt tēmu"
|
||||
}
|
||||
@@ -92,5 +92,9 @@
|
||||
"notifications_click_open_button": "Отвори линк",
|
||||
"notifications_actions_open_url_title": "Оди на {{url}}",
|
||||
"notifications_actions_not_supported": "Дејството не е поддржано во веб-апликацијата",
|
||||
"notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}"
|
||||
"notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}",
|
||||
"notifications_none_for_any_title": "Не сте добиле никакви известувања.",
|
||||
"notifications_actions_failed_notification": "Неуспешно дејство",
|
||||
"notifications_none_for_topic_title": "Сè уште не сте добиле никакви известувања за оваа тема.",
|
||||
"publish_dialog_filename_label": "Име на фајл"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -2,7 +2,7 @@
|
||||
"action_bar_clear_notifications": "Limpar todas as notificações",
|
||||
"action_bar_send_test_notification": "Enviar notificação de teste",
|
||||
"action_bar_unsubscribe": "Anular subscrição",
|
||||
"action_bar_toggle_mute": "Ativa/Desativa notificações",
|
||||
"action_bar_toggle_mute": "Ativar/Desativar notificações",
|
||||
"action_bar_toggle_action_menu": "Abrir/fechar menu de ação",
|
||||
"message_bar_type_message": "Escreva uma mensagem aqui",
|
||||
"message_bar_error_publishing": "Erro ao publicar notificação",
|
||||
@@ -70,11 +70,11 @@
|
||||
"publish_dialog_topic_label": "Nome do tópico",
|
||||
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo: \"avisos_do_filipe\"",
|
||||
"publish_dialog_topic_reset": "Limpar tópico",
|
||||
"publish_dialog_title_placeholder": "Título da notificação, por exemplo: \"Alerta de espaço em disco\"",
|
||||
"publish_dialog_title_placeholder": "Título da notificação, p.ex: \"Alerta de espaço em disco\"",
|
||||
"publish_dialog_message_label": "Mensagem",
|
||||
"publish_dialog_message_placeholder": "Escreva uma mensagem aqui",
|
||||
"publish_dialog_tags_label": "Etiquetas",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: aviso, srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas separadas por vírgula, p.ex.: aviso, srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioridade",
|
||||
"publish_dialog_click_label": "URL de clique",
|
||||
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
|
||||
@@ -404,5 +404,7 @@
|
||||
"web_push_subscription_expiring_title": "As notificações serão pausadas",
|
||||
"web_push_subscription_expiring_body": "Abra o ntfy para continuar recebendo notificações",
|
||||
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
|
||||
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web"
|
||||
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido"
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@
|
||||
"publish_dialog_topic_label": "Nome do tópico",
|
||||
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo, phil_alerts",
|
||||
"publish_dialog_title_label": "Título",
|
||||
"publish_dialog_title_placeholder": "Título da notificação, por exemplo Alerta de espaço em disco",
|
||||
"publish_dialog_title_placeholder": "Título da notificação, p.ex.: \"Alerta de espaço em disco\"",
|
||||
"publish_dialog_message_label": "Mensagem",
|
||||
"publish_dialog_message_placeholder": "Digite uma mensagem aqui",
|
||||
"publish_dialog_tags_label": "Etiquetas",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, p.ex.: aviso, srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioridade",
|
||||
"publish_dialog_click_label": "Clique em URL",
|
||||
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
|
||||
@@ -112,7 +112,7 @@
|
||||
"common_add": "Adicionar",
|
||||
"common_save": "Salvar",
|
||||
"prefs_appearance_title": "Aparência",
|
||||
"prefs_appearance_language_title": "LInguagem",
|
||||
"prefs_appearance_language_title": "Idioma",
|
||||
"priority_min": "minima",
|
||||
"priority_low": "baixa",
|
||||
"priority_default": "padrão",
|
||||
@@ -120,7 +120,7 @@
|
||||
"priority_max": "máxima",
|
||||
"error_boundary_title": "Ah não, ntfy parou de funcionar",
|
||||
"error_boundary_gathering_info": "Coletar mais informações …",
|
||||
"error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isto.<br/>Se tiver um minuto, por favor <githubLink> relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isso.<br/>Se tiver um minuto, por favor <githubLink>relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Copiar rastreamento de pilha",
|
||||
"error_boundary_stack_trace": "Rastreamento de pilha",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "excede {{fileSizeLimit}} limite de arquivo e cota, {{remainingBytes}} restante",
|
||||
@@ -404,5 +404,7 @@
|
||||
"web_push_subscription_expiring_title": "As notificações serão pausadas",
|
||||
"web_push_subscription_expiring_body": "Abra o ntfy para continuar recebendo notificações",
|
||||
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
|
||||
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web"
|
||||
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Um usuário provisionado não pode ser editado ou apagado",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"notifications_none_for_any_description": "Чтобы отправить уведомление на тему, просто сделаете PUT или POST-запрос на её URL-адрес. Вот пример с использованием одной из ваших тем.",
|
||||
"notifications_no_subscriptions_title": "Похоже, что у вас ещё нет подписок.",
|
||||
"alert_notification_permission_required_description": "Предоставьте браузеру разрешение на отображение уведомлений на рабочем столе",
|
||||
"notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого Вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.",
|
||||
"notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.",
|
||||
"notifications_example": "Пример",
|
||||
"notifications_more_details": "Для более подробной информации, посетите <websiteLink>наш сайт</websiteLink> или <docsLink>документацию</docsLink>.",
|
||||
"notifications_loading": "Идет загрузка уведомлений …",
|
||||
@@ -66,9 +66,9 @@
|
||||
"notifications_click_open_button": "Открыть ссылку",
|
||||
"subscribe_dialog_subscribe_title": "Подписаться на тему",
|
||||
"publish_dialog_button_cancel": "Отмена",
|
||||
"subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки Вы сможете отправлять уведомления используя PUT/POST-запросы.",
|
||||
"subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки вы сможете отправлять уведомления используя PUT/POST-запросы.",
|
||||
"prefs_users_description": "Вы можете управлять пользователями для защищённых тем. Учтите, что имя учётные данные хранятся в локальном хранилище браузера.",
|
||||
"error_boundary_description": "Это не должно было случиться. Нам очень жаль. <br/>Если Вы можете уделить минуту своего времени, пожалуйста <githubLink>сообщите об этом на GitHub</githubLink>, или дайте нам знать через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_description": "Это не должно было случиться. Нам очень жаль. <br/>Если вы можете уделить минуту своего времени, пожалуйста <githubLink>сообщите об этом на GitHub</githubLink>, или дайте нам знать через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"publish_dialog_email_placeholder": "Адрес для пересылки уведомления. Например, phil@example.com",
|
||||
"publish_dialog_attach_placeholder": "Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_label": "Имя файла",
|
||||
@@ -155,19 +155,19 @@
|
||||
"action_bar_show_menu": "Показать меню",
|
||||
"action_bar_logo_alt": "Логотип ntfy",
|
||||
"emoji_picker_search_clear": "Сбросить поиск",
|
||||
"account_upgrade_dialog_cancel_warning": "Это действие <strong>отменит Вашу подписку</strong> и переведет Вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше <strong>будут удалены</strong>.",
|
||||
"account_upgrade_dialog_cancel_warning": "Это действие <strong>отменит вашу подписку</strong> и переведет вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше <strong>будут удалены</strong>.",
|
||||
"account_tokens_table_create_token_button": "Создать токен доступа",
|
||||
"account_tokens_table_last_origin_tooltip": "С IP-адреса {{ip}}, нажмите для подробностей",
|
||||
"account_tokens_dialog_title_edit": "Изменить токен доступа",
|
||||
"account_delete_dialog_button_cancel": "Отмена",
|
||||
"account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У Вас не будет доступа к порталу оплаты.",
|
||||
"account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У вас не будет доступа к порталу оплаты.",
|
||||
"account_delete_dialog_description": "Это действие безвозвратно удалит вашу учётную запись, включая все данные, хранящиеся на сервере. После удаления имя пользователя вашей учётной записи не будет доступно для регистрации в течение 7 дней. Если вы точно хотите продолжить, пожалуйста, введите свой пароль ниже.",
|
||||
"account_delete_dialog_label": "Пароль",
|
||||
"reservation_delete_dialog_action_keep_description": "Сообщения и вложения которые находятся в кэше сервера станут доступны всем, кто знает имя темы.",
|
||||
"prefs_reservations_table": "Список зарезервированных тем",
|
||||
"prefs_reservations_table_access_header": "Доступ",
|
||||
"prefs_reservations_table_everyone_write_only": "Я могу публиковать и подписываться, все остальные могут публиковать",
|
||||
"prefs_reservations_dialog_description": "Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.",
|
||||
"prefs_reservations_dialog_description": "Резервирование дает вам возможность управлять темой и настраивать правила доступа к ней для пользователей.",
|
||||
"reservation_delete_dialog_action_delete_title": "Удалить сообщения в кэше и вложения",
|
||||
"reservation_delete_dialog_action_delete_description": "Сообщения в кэше и вложения будут безвозвратно удалены. Это действие невозможно отменить.",
|
||||
"prefs_reservations_table_not_subscribed": "Не подписан",
|
||||
@@ -178,10 +178,10 @@
|
||||
"prefs_reservations_dialog_title_delete": "Удалить резервирование",
|
||||
"prefs_reservations_dialog_title_edit": "Изменение резервированной темы",
|
||||
"prefs_reservations_table_topic_header": "Тема",
|
||||
"prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с Вашей учетной записью.",
|
||||
"prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с вашей учетной записью.",
|
||||
"prefs_users_delete_button": "Удалить пользователя",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Невозможно удалить или редактировать залогиненного пользователя",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы одну зарезервированную тему</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы одну зарезервированную тему</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Пересчёт оплаты</strong>: при расширении подписки, разница в цене от текущей <strong>спишется сразу</strong>. При упрощении подписки, неиспользованные средства пойдут в оплату баланса по следующим счетам.",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл",
|
||||
"account_tokens_table_never_expires": "Никогда",
|
||||
@@ -191,7 +191,7 @@
|
||||
"error_boundary_unsupported_indexeddb_title": "Работа в приватном режиме не поддерживается",
|
||||
"account_tokens_dialog_button_create": "Создать токен",
|
||||
"account_tokens_delete_dialog_submit_button": "Безвозвратно удалить токен",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы {{count}} зарезервированных тем</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы {{count}} зарезервированных тем</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} сообщений в день",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} суммарный объем",
|
||||
"account_upgrade_dialog_tier_selected_label": "Выбранная",
|
||||
@@ -268,7 +268,7 @@
|
||||
"notifications_attachment_file_document": "другой тип файла",
|
||||
"notifications_actions_not_supported": "Действие не поддерживается в веб-приложении",
|
||||
"display_name_dialog_title": "Изменить псевдоним",
|
||||
"display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке Ваших подписок. Это помогает легче находить темы со сложными именами.",
|
||||
"display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке ваших подписок. Это помогает легче находить темы со сложными именами.",
|
||||
"reserve_dialog_checkbox_label": "Зарезервировать тему и настроить доступ",
|
||||
"publish_dialog_emoji_picker_show": "Выбрать смайлик",
|
||||
"publish_dialog_click_reset": "Удалить ссылку",
|
||||
@@ -312,7 +312,7 @@
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Отменить подписку",
|
||||
"account_upgrade_dialog_button_update_subscription": "Изменить подписку",
|
||||
"account_tokens_title": "Токены доступа",
|
||||
"account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные Вашей учетной записи. Смотрите <Link>документацию</Link> чтобы узнать больше.",
|
||||
"account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные вашей учетной записи. Смотрите <Link>документацию</Link> чтобы узнать больше.",
|
||||
"account_tokens_table_token_header": "Токен",
|
||||
"account_tokens_table_label_header": "Название",
|
||||
"account_tokens_table_last_access_header": "Последний доступ",
|
||||
@@ -340,7 +340,7 @@
|
||||
"account_basics_password_dialog_confirm_password_label": "Подтвердите пароль",
|
||||
"account_basics_password_dialog_button_submit": "Сменить пароль",
|
||||
"account_basics_tier_title": "Тип учётной записи",
|
||||
"error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается Вашим браузером в приватном режиме.<br/><br/>Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в <githubLink>этом отчете на GitHub</githubLink> или связавшись с нами через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается вашим браузером в приватном режиме.<br/><br/>Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в <githubLink>этом отчете на GitHub</githubLink> или связавшись с нами через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"account_basics_tier_interval_monthly": "ежемесячно",
|
||||
"account_basics_tier_interval_yearly": "ежегодно",
|
||||
"account_upgrade_dialog_interval_yearly": "Ежегодно",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"common_cancel": "Prekliči",
|
||||
"common_save": "Shrani",
|
||||
"common_add": "Dodaj",
|
||||
"common_back": "Nazaj",
|
||||
"common_copy_to_clipboard": "Kopiraj v odložišče",
|
||||
"signup_title": "Ustvari ntfy račun",
|
||||
"signup_form_username": "Uporabniško ime",
|
||||
"signup_form_password": "Geslo",
|
||||
"signup_form_confirm_password": "Potrditev gesla",
|
||||
"signup_form_button_submit": "Registracija",
|
||||
"signup_form_toggle_password_visibility": "Prikaži geslo",
|
||||
"signup_already_have_account": "Že imate račun? Prijavite se!",
|
||||
"signup_disabled": "Registracija je onemogočena",
|
||||
"signup_error_username_taken": "Uporabniško ime {{username}} je zasedeno",
|
||||
"signup_error_creation_limit_reached": "Omejitev registracije novih računov je presežena",
|
||||
"login_title": "Prijava v vaš ntfy račun",
|
||||
"login_form_button_submit": "Prijava",
|
||||
"login_link_signup": "Registracija",
|
||||
"login_disabled": "Prijava je onemogočena",
|
||||
"action_bar_show_menu": "Prikaži menu",
|
||||
"action_bar_logo_alt": "ntfy logotip",
|
||||
"action_bar_settings": "Nastavitve",
|
||||
"action_bar_account": "Račun",
|
||||
"action_bar_change_display_name": "Spremenite prikazno ime",
|
||||
"action_bar_reservation_add": "Rezerviraj temo",
|
||||
"action_bar_reservation_edit": "Spremenite rezervacijo",
|
||||
"action_bar_reservation_delete": "Odstranite rezervacijo",
|
||||
"action_bar_reservation_limit_reached": "Omejitev dosežena",
|
||||
"action_bar_send_test_notification": "Pošljite testno obvestilo",
|
||||
"action_bar_clear_notifications": "Počistite vsa obvestila",
|
||||
"action_bar_mute_notifications": "Izklopite zvok obvestil",
|
||||
"action_bar_unmute_notifications": "Vklopite zvok obvestil",
|
||||
"action_bar_unsubscribe": "Odjava",
|
||||
"action_bar_toggle_mute": "Vklopite/izklopite zvok obvestil",
|
||||
"action_bar_toggle_action_menu": "Odprite/zaprite akcijski menu",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Nastavitve",
|
||||
"action_bar_profile_logout": "Odjavite se",
|
||||
"action_bar_sign_in": "Prijavite se",
|
||||
"action_bar_sign_up": "Registrirajte se",
|
||||
"message_bar_type_message": "Vpišite sporočilo",
|
||||
"message_bar_error_publishing": "Napaka pri objavi obvestila",
|
||||
"message_bar_show_dialog": "Prikaži pogovorno okno za objavo",
|
||||
"message_bar_publish": "Objavi sporočilo",
|
||||
"nav_topics_title": "Naročene teme",
|
||||
"nav_button_all_notifications": "Vsa obvestila",
|
||||
"nav_button_account": "Račun",
|
||||
"nav_button_settings": "Nastavitve",
|
||||
"nav_button_documentation": "Dokumentacija",
|
||||
"nav_button_publish_message": "Objavi obvestilo",
|
||||
"publish_dialog_title_no_topic": "Objavi obvestilo",
|
||||
"publish_dialog_progress_uploading": "Nalaganje …",
|
||||
"publish_dialog_progress_uploading_detail": "Nalaganje {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "Obvestilo objavljeno",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "presega {{fileSizeLimit}} omejitev velikosti datoteke in kvote, {{remainingBytes}} še preostalo",
|
||||
"publish_dialog_attachment_limits_file_reached": "presega {{fileSizeLimit}} omejitev velikosti datoteke",
|
||||
"publish_dialog_attachment_limits_quota_reached": "presega kvoto, {{remainingBytes}} še preostalo",
|
||||
"publish_dialog_emoji_picker_show": "Izberite emoji",
|
||||
"publish_dialog_priority_min": "Najnižja prioriteta",
|
||||
"publish_dialog_priority_low": "Nizka prioriteta",
|
||||
"publish_dialog_priority_default": "Privzeta prioriteta",
|
||||
"publish_dialog_priority_high": "Visoka prioriteta",
|
||||
"publish_dialog_priority_max": "Najvišja prioriteta",
|
||||
"publish_dialog_base_url_label": "URL storitve",
|
||||
"publish_dialog_base_url_placeholder": "URL storitve, npr. https://example.com",
|
||||
"publish_dialog_topic_label": "Naziv teme",
|
||||
"publish_dialog_topic_placeholder": "Naziv teme, npr. janez_alarmi",
|
||||
"publish_dialog_topic_reset": "Ponastavitev temo",
|
||||
"publish_dialog_title_label": "Naslov",
|
||||
"publish_dialog_title_placeholder": "Naslov obvestila, npr. Primanjkuje prostora",
|
||||
"publish_dialog_message_label": "Sporočilo",
|
||||
"publish_dialog_message_placeholder": "Vpišite sporočilo",
|
||||
"publish_dialog_tags_label": "Značke",
|
||||
"publish_dialog_tags_placeholder": "Z vejico ločen seznam značk, npr. opozorilo, srv1-kopija",
|
||||
"publish_dialog_priority_label": "Prioriteta",
|
||||
"publish_dialog_click_label": "URL za klik",
|
||||
"publish_dialog_click_placeholder": "URL ki se odpre, ko kliknete na obvestilo",
|
||||
"publish_dialog_click_reset": "Odstranite URL za klik",
|
||||
"publish_dialog_email_label": "E-naslov",
|
||||
"publish_dialog_email_placeholder": "E-naslov za posredovanje obvestil, npr. janez@example.com",
|
||||
"publish_dialog_email_reset": "Odstranite e-naslov za posredovanje",
|
||||
"publish_dialog_call_label": "Telefonski klic",
|
||||
"publish_dialog_call_item": "Pokličite telefonsko številko {{number}}",
|
||||
"publish_dialog_call_reset": "Odstranite telefonski klic",
|
||||
"publish_dialog_attach_label": "URL priponke",
|
||||
"publish_dialog_attach_placeholder": "Pripnite datoteko preko URL povezave, npr. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "Odstranite URL priponke",
|
||||
"publish_dialog_filename_label": "Ime datoteke",
|
||||
"publish_dialog_filename_placeholder": "Ime priponke",
|
||||
"publish_dialog_delay_label": "Zamik",
|
||||
"publish_dialog_delay_placeholder": "Zamik dostave, npr. {{unixTimestamp}}, {{relativeTime}}, ali \"{{naturalLanguage}}\" (v angleškem jeziku)",
|
||||
"publish_dialog_delay_reset": "Odstranite zamik dostave",
|
||||
"publish_dialog_other_features": "Ostale funkcije:",
|
||||
"publish_dialog_chip_click_label": "URL za klik",
|
||||
"publish_dialog_chip_email_label": "Posredujte na e-naslov",
|
||||
"publish_dialog_chip_call_label": "Telefonski klic",
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Ni verificiranih telefonskih številk",
|
||||
"publish_dialog_chip_attach_url_label": "Pripnite datoteko preko URL",
|
||||
"publish_dialog_chip_attach_file_label": "Pripnite lokalno datoteko",
|
||||
"publish_dialog_chip_delay_label": "Zamik dostave",
|
||||
"publish_dialog_chip_topic_label": "Spremenite temo",
|
||||
"publish_dialog_details_examples_description": "Za vzorčne primere in natančnejše opise vseh funkcij pošiljanja se posvetujte z <docsLink>dokumentacijo</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Prekličite pošiljanje",
|
||||
"publish_dialog_button_cancel": "Prekliči",
|
||||
"publish_dialog_button_send": "Pošlji",
|
||||
"publish_dialog_checkbox_markdown": "Oblikovanje kot Markdown",
|
||||
"publish_dialog_checkbox_publish_another": "Objavite še eno",
|
||||
"publish_dialog_attached_file_title": "Priponka:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Ime priponke",
|
||||
"publish_dialog_attached_file_remove": "Odstranite priponko",
|
||||
"publish_dialog_drop_file_here": "Povleci in spusti",
|
||||
"emoji_picker_search_placeholder": "Išči emoji",
|
||||
"emoji_picker_search_clear": "Ponastavi iskanje",
|
||||
"subscribe_dialog_subscribe_title": "Naročite se na temo"
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"nav_button_muted": "Сповіщення вимкнено",
|
||||
"nav_button_connecting": "підключення",
|
||||
"alert_notification_permission_required_title": "Сповіщення вимкнено",
|
||||
"alert_notification_permission_required_description": "Дозвольте браузеру показувати сповіщення.",
|
||||
"alert_notification_permission_required_description": "Дозвольте браузеру показувати сповіщення на робочому столі",
|
||||
"alert_notification_permission_required_button": "Дозволити",
|
||||
"alert_not_supported_title": "Сповіщення не підтримуються",
|
||||
"notifications_list_item": "Сповіщення",
|
||||
@@ -34,11 +34,11 @@
|
||||
"publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts",
|
||||
"publish_dialog_topic_reset": "Скинути тему",
|
||||
"publish_dialog_title_label": "Заголовок",
|
||||
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад Сповіщення про дисковий простір",
|
||||
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостатньо місця на диску",
|
||||
"publish_dialog_message_label": "Повідомлення",
|
||||
"publish_dialog_message_placeholder": "Введіть повідомлення",
|
||||
"publish_dialog_tags_label": "Теги",
|
||||
"publish_dialog_tags_placeholder": "Список тегів розділений комою, наприклад warning, srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "Список тегів розділений комою, наприклад, warning, srv1-backup",
|
||||
"publish_dialog_click_placeholder": "URL-адреса, яка відкривається після натискання сповіщення",
|
||||
"publish_dialog_email_label": "Електронна пошта",
|
||||
"publish_dialog_attach_placeholder": "Прикріпіть файл за URL-адресою, наприклад https://f-droid.org/F-Droid.apk",
|
||||
@@ -300,7 +300,7 @@
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище",
|
||||
"account_upgrade_dialog_tier_current_label": "Поточний",
|
||||
"account_upgrade_dialog_tier_selected_label": "Вибране",
|
||||
"account_upgrade_dialog_cancel_warning": "Це <strong> скасує вашу підписку</strong> і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері <strong>, буде видалено</strong>.",
|
||||
"account_upgrade_dialog_cancel_warning": "Ця дія <strong>скасує вашу підписку</strong>, і знизить версію вашого облікового запису {{date}}. Відповідно, в цю дату, резервування тем, а також повідомлення, кешовані на сервері, <strong>буде видалено</strong>.",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день",
|
||||
@@ -397,12 +397,14 @@
|
||||
"prefs_notifications_web_push_disabled_description": "Сповіщення надходитимуть якщо вебзастосунок запущений (за допомоги WebSocket)",
|
||||
"prefs_notifications_web_push_enabled": "Увімкнено для {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Вимкнено",
|
||||
"prefs_appearance_theme_title": "Тема",
|
||||
"prefs_appearance_theme_title": "Тема оформлення",
|
||||
"prefs_appearance_theme_system": "Система (за замовчуванням)",
|
||||
"prefs_appearance_theme_light": "Світлий режим",
|
||||
"error_boundary_button_reload_ntfy": "Перезавантажити ntfy",
|
||||
"web_push_subscription_expiring_title": "Сповіщення буде призупинено",
|
||||
"web_push_subscription_expiring_body": "Відкрийте ntfy, щоб продовжити отримувати сповіщення",
|
||||
"web_push_unknown_notification_body": "Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку",
|
||||
"alert_notification_ios_install_required_title": "потрібно встановити на iOS"
|
||||
"alert_notification_ios_install_required_title": "Необхідне встановлення на iOS",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Автоматично створеного користувача не можна редагувати чи видалити",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити"
|
||||
}
|
||||
|
||||
+85
-13
@@ -4,6 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||
import { NetworkFirst } from "workbox-strategies";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
import { dbAsync } from "../src/app/db";
|
||||
import session from "../src/app/Session";
|
||||
import { ACTION_HTTP, ACTION_VIEW } from "../src/app/actions";
|
||||
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
|
||||
import initI18n from "../src/app/i18n";
|
||||
@@ -11,8 +12,9 @@ import {
|
||||
EVENT_MESSAGE,
|
||||
EVENT_MESSAGE_CLEAR,
|
||||
EVENT_MESSAGE_DELETE,
|
||||
WEBPUSH_EVENT_MESSAGE,
|
||||
WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
|
||||
SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG,
|
||||
SW_WEBPUSH_EVENT_MESSAGE,
|
||||
SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
|
||||
} from "../src/app/events";
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,7 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
||||
*/
|
||||
const handlePushMessage = async (data) => {
|
||||
const { subscription_id: subscriptionId, message } = data;
|
||||
|
||||
const db = await dbAsync();
|
||||
|
||||
console.log("[ServiceWorker] Message received", data);
|
||||
@@ -43,9 +46,24 @@ const handlePushMessage = async (data) => {
|
||||
const subscription = await db.subscriptions.get(subscriptionId);
|
||||
if (!subscription) {
|
||||
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||
handlePushUnknown(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: As soon as possible, to avoid this Safari error:
|
||||
// > Push event handling completed without showing any notification via
|
||||
// > ServiceWorkerRegistration.showNotification(). This may trigger removal of
|
||||
// > the push subscription.
|
||||
await self.registration.showNotification(
|
||||
...toNotificationParams({
|
||||
message,
|
||||
defaultTitle: message.topic,
|
||||
topicRoute: new URL(message.topic, self.location.origin).toString(),
|
||||
baseUrl: subscription.baseUrl,
|
||||
topic: subscription.topic,
|
||||
})
|
||||
);
|
||||
|
||||
// Delete existing notification with same sequence ID (if any)
|
||||
const sequenceId = message.sequence_id || message.id;
|
||||
if (sequenceId) {
|
||||
@@ -71,17 +89,71 @@ const handlePushMessage = async (data) => {
|
||||
// Broadcast the message to potentially play a sound
|
||||
broadcastChannel.postMessage(message);
|
||||
|
||||
await self.registration.showNotification(
|
||||
...toNotificationParams({
|
||||
message,
|
||||
defaultTitle: message.topic,
|
||||
topicRoute: new URL(message.topic, self.location.origin).toString(),
|
||||
baseUrl: subscription.baseUrl,
|
||||
topic: subscription.topic,
|
||||
})
|
||||
);
|
||||
await maybeExtendToken();
|
||||
};
|
||||
|
||||
const refreshTokenThreshold = 1000 * 60 * 60; // 1 hour
|
||||
const maybeExtendToken = async () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("[ServiceWorker] Skipping token extension in development since no config.base_url exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await session.tokenAsync();
|
||||
if (!token) {
|
||||
console.debug("[ServiceWorker] No session token, skipping token extension");
|
||||
return;
|
||||
}
|
||||
|
||||
const lastExtendedAt = await session.lastExtendedAtAsync();
|
||||
const now = Date.now();
|
||||
|
||||
if (lastExtendedAt && now - lastExtendedAt < refreshTokenThreshold) {
|
||||
console.debug(`[ServiceWorker] Token extended ${Math.floor((now - lastExtendedAt) / 1000 / 60)} minutes ago, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[ServiceWorker] Extending user access token");
|
||||
|
||||
// duplicated from utils.js#accountTokenUrl since we can't import that here
|
||||
// as long as there's mp3 and other incompatible imports there
|
||||
const tokenUrl = `${config.base_url}/v1/account/token`;
|
||||
|
||||
try {
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await session.setLastExtendedAtAsync();
|
||||
console.log(`[ServiceWorker] Token extended successfully`);
|
||||
} else {
|
||||
console.error(`[ServiceWorker] Failed to extend token: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ServiceWorker] Failed to extend token", e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a periodic-sync listener for `extend_token` (see hooks.js).
|
||||
* This extends the token regardless of whether the browser is open.
|
||||
*
|
||||
* CAVEATS:
|
||||
* - Chromium-only
|
||||
* - Only when the PWA is _installed_ (not just running in a browser tab)
|
||||
* - Only when notifications are granted
|
||||
*/
|
||||
self.addEventListener("periodicsync", (event) => {
|
||||
if (event.tag === SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG) {
|
||||
console.log(`[ServiceWorker] Received periodicsync event "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`);
|
||||
event.waitUntil(maybeExtendToken());
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle a message_delete event: delete the notification from the database.
|
||||
*/
|
||||
@@ -192,7 +264,7 @@ const handlePush = async (data) => {
|
||||
// - Web app: hooks.js:handleNotification()
|
||||
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
|
||||
|
||||
if (data.event === WEBPUSH_EVENT_MESSAGE) {
|
||||
if (data.event === SW_WEBPUSH_EVENT_MESSAGE) {
|
||||
const { message } = data;
|
||||
if (message.event === EVENT_MESSAGE) {
|
||||
return await handlePushMessage(data);
|
||||
@@ -201,7 +273,7 @@ const handlePush = async (data) => {
|
||||
} else if (message.event === EVENT_MESSAGE_CLEAR) {
|
||||
return await handlePushMessageClear(data);
|
||||
}
|
||||
} else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
|
||||
} else if (data.event === SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
|
||||
return await handlePushSubscriptionExpiring(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -137,8 +137,8 @@ class AccountApi {
|
||||
token,
|
||||
label,
|
||||
};
|
||||
if (expires > 0) {
|
||||
body.expires = Math.floor(Date.now() / 1000) + expires;
|
||||
if (expires >= 0) {
|
||||
body.expires = expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0;
|
||||
}
|
||||
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
@@ -155,6 +155,7 @@ class AccountApi {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
});
|
||||
await session.setLastExtendedAtAsync();
|
||||
}
|
||||
|
||||
async deleteToken(token) {
|
||||
|
||||
@@ -36,6 +36,7 @@ class Session {
|
||||
await this.db.kv.bulkPut([
|
||||
{ key: "user", value: username },
|
||||
{ key: "token", value: token },
|
||||
{ key: "lastExtendedAt", value: Date.now() },
|
||||
]);
|
||||
localStorage.setItem("user", username);
|
||||
localStorage.setItem("token", token);
|
||||
@@ -52,6 +53,18 @@ class Session {
|
||||
return (await this.db.kv.get({ key: "user" }))?.value;
|
||||
}
|
||||
|
||||
async tokenAsync() {
|
||||
return (await this.db.kv.get({ key: "token" }))?.value;
|
||||
}
|
||||
|
||||
async lastExtendedAtAsync() {
|
||||
return (await this.db.kv.get({ key: "lastExtendedAt" }))?.value;
|
||||
}
|
||||
|
||||
async setLastExtendedAtAsync() {
|
||||
await this.db.kv.put({ key: "lastExtendedAt", value: Date.now() });
|
||||
}
|
||||
|
||||
exists() {
|
||||
return this.username() && this.token();
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ export const EVENT_MESSAGE_DELETE = "message_delete";
|
||||
export const EVENT_MESSAGE_CLEAR = "message_clear";
|
||||
export const EVENT_POLL_REQUEST = "poll_request";
|
||||
|
||||
export const WEBPUSH_EVENT_MESSAGE = "message";
|
||||
export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
|
||||
export const SW_WEBPUSH_EVENT_MESSAGE = "message";
|
||||
export const SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
|
||||
|
||||
export const SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG = "extend_token";
|
||||
|
||||
// Check if an event is a notification event (message, delete, or read)
|
||||
export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;
|
||||
|
||||
@@ -35,7 +35,7 @@ export const formatMessage = (m) => {
|
||||
return m.message || "";
|
||||
};
|
||||
|
||||
const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
|
||||
export const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
|
||||
export const isImage = (attachment) => {
|
||||
if (!attachment) return false;
|
||||
|
||||
|
||||
+37
-6
@@ -1,4 +1,3 @@
|
||||
import { Base64 } from "js-base64";
|
||||
import beep from "../sounds/beep.mp3";
|
||||
import juntos from "../sounds/juntos.mp3";
|
||||
import pristine from "../sounds/pristine.mp3";
|
||||
@@ -63,9 +62,14 @@ export const unmatchedTags = (tags) => {
|
||||
return tags.filter((tag) => !(tag in emojisMapped));
|
||||
};
|
||||
|
||||
export const encodeBase64 = (s) => Base64.encode(s);
|
||||
export const encodeBase64 = (s) => {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
export const encodeBase64Url = (s) => Base64.encodeURI(s);
|
||||
export const encodeBase64Url = (s) => encodeBase64(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
||||
export const bearerAuth = (token) => `Bearer ${token}`;
|
||||
|
||||
@@ -138,9 +142,10 @@ export const hashCode = (s) => {
|
||||
|
||||
/**
|
||||
* convert `i18n.language` style str (e.g.: `en_US`) to kebab-case (e.g.: `en-US`),
|
||||
* which is expected by `<html lang>` and `Intl.DateTimeFormat`
|
||||
* which is expected by `<html lang>` and `Intl.DateTimeFormat`. Falls back to "en"
|
||||
* if the input is missing or not a string.
|
||||
*/
|
||||
export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-");
|
||||
export const getKebabCaseLangStr = (language) => (typeof language === "string" && language.length > 0 ? language.replace(/_/g, "-") : "en");
|
||||
|
||||
export const formatShortDateTime = (timestamp, language) =>
|
||||
new Intl.DateTimeFormat(getKebabCaseLangStr(language), {
|
||||
@@ -151,6 +156,32 @@ export const formatShortDateTime = (timestamp, language) =>
|
||||
export const formatShortDate = (timestamp, language) =>
|
||||
new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(new Date(timestamp * 1000));
|
||||
|
||||
export const formatShortDuration = (ms, language) => {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const units = [
|
||||
{ unit: "year", s: 31536000 },
|
||||
{ unit: "month", s: 2592000 },
|
||||
{ unit: "week", s: 604800 },
|
||||
{ unit: "day", s: 86400 },
|
||||
{ unit: "hour", s: 3600 },
|
||||
{ unit: "minute", s: 60 },
|
||||
{ unit: "second", s: 1 },
|
||||
];
|
||||
const match = units.find((u) => seconds >= u.s) ?? units[units.length - 1];
|
||||
const value = Math.round(seconds / match.s);
|
||||
// [lang, "en"] makes Intl fall back to English for well-formed-but-unsupported tags;
|
||||
// the try/catch covers malformed tags (RangeError) so the web app never crashes here.
|
||||
try {
|
||||
return new Intl.NumberFormat([getKebabCaseLangStr(language), "en"], {
|
||||
style: "unit",
|
||||
unit: match.unit,
|
||||
unitDisplay: "long",
|
||||
}).format(value);
|
||||
} catch {
|
||||
return new Intl.NumberFormat("en", { style: "unit", unit: match.unit, unitDisplay: "long" }).format(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return "0 bytes";
|
||||
const k = 1024;
|
||||
@@ -178,7 +209,7 @@ export const formatPrice = (n) => {
|
||||
};
|
||||
|
||||
export const openUrl = (url) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.open(url, "_blank", "noreferrer");
|
||||
};
|
||||
|
||||
export const sounds = {
|
||||
|
||||
@@ -39,13 +39,12 @@ import EditIcon from "@mui/icons-material/Edit";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import humanizeDuration from "humanize-duration";
|
||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { ContentCopy, Public } from "@mui/icons-material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import routes from "./routes";
|
||||
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
|
||||
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
|
||||
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
||||
import { Pref, PrefGroup } from "./Pref";
|
||||
import db from "../app/db";
|
||||
@@ -896,10 +895,7 @@ const Stats = () => {
|
||||
title={t("account_usage_attachment_storage_title")}
|
||||
description={t("account_usage_attachment_storage_description", {
|
||||
filesize: formatBytes(account.limits.attachment_file_size),
|
||||
expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
|
||||
language: i18n.resolvedLanguage,
|
||||
fallbacks: ["en"],
|
||||
}),
|
||||
expiry: formatShortDuration(account.limits.attachment_expiry_duration * 1000, i18n.resolvedLanguage),
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
@@ -1056,87 +1052,94 @@ const TokensTable = (props) => {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tokens.map((token) => (
|
||||
<TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
|
||||
aria-label={t("account_tokens_table_token_header")}
|
||||
>
|
||||
<span>
|
||||
<span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
|
||||
...
|
||||
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
|
||||
<IconButton onClick={() => handleCopy(token.token)}>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell aria-label={t("account_tokens_table_label_header")}>
|
||||
{token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
|
||||
{token.token !== session.token() && (token.label || "-")}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
|
||||
{token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<span>{formatShortDateTime(token.last_access, i18n.language)}</span>
|
||||
<Tooltip
|
||||
title={t("account_tokens_table_last_origin_tooltip", {
|
||||
ip: token.last_origin,
|
||||
})}
|
||||
>
|
||||
<IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
|
||||
<Public />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
{token.token !== session.token() && !token.provisioned && (
|
||||
<>
|
||||
<Tooltip title={t("account_tokens_dialog_title_edit")}>
|
||||
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
|
||||
<EditIcon />
|
||||
{tokens.map((token) => {
|
||||
const hasLastAccess = Number.isFinite(token.last_access) && token.last_access > 0;
|
||||
const hasLastOrigin = !!token.last_origin;
|
||||
|
||||
return (
|
||||
<TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
|
||||
aria-label={t("account_tokens_table_token_header")}
|
||||
>
|
||||
<span>
|
||||
<span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
|
||||
...
|
||||
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
|
||||
<IconButton onClick={() => handleCopy(token.token)}>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("account_tokens_dialog_title_delete")}>
|
||||
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell aria-label={t("account_tokens_table_label_header")}>
|
||||
{token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
|
||||
{token.token !== session.token() && (token.label || "-")}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
|
||||
{token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{hasLastAccess ? <span>{formatShortDateTime(token.last_access, i18n.language)}</span> : <em>-</em>}
|
||||
{hasLastOrigin && (
|
||||
<Tooltip
|
||||
title={t("account_tokens_table_last_origin_tooltip", {
|
||||
ip: token.last_origin,
|
||||
})}
|
||||
>
|
||||
<IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
|
||||
<Public />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
{token.token !== session.token() && !token.provisioned && (
|
||||
<>
|
||||
<Tooltip title={t("account_tokens_dialog_title_edit")}>
|
||||
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("account_tokens_dialog_title_delete")}>
|
||||
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{token.token === session.token() && (
|
||||
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
|
||||
<span>
|
||||
<IconButton disabled>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{token.token === session.token() && (
|
||||
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
|
||||
<span>
|
||||
<IconButton disabled>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{token.provisioned && (
|
||||
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
|
||||
<span>
|
||||
<IconButton disabled>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
{token.provisioned && (
|
||||
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
|
||||
<span>
|
||||
<IconButton disabled>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
<Portal>
|
||||
<Snackbar
|
||||
|
||||
@@ -31,18 +31,24 @@ const AttachmentIcon = (props) => {
|
||||
imageFile = fileDocument;
|
||||
imageLabel = t("notifications_attachment_file_document");
|
||||
}
|
||||
const icon = (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
alt={imageLabel}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (!props.href) {
|
||||
return icon;
|
||||
}
|
||||
return (
|
||||
<Link href={props.href} target="_blank">
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
alt={imageLabel}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
}}
|
||||
/>
|
||||
<Link href={props.href} target="_blank" rel="noopener noreferrer">
|
||||
{icon}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import StackTrace from "stacktrace-js";
|
||||
import { CircularProgress, Link, Button } from "@mui/material";
|
||||
import { Link, Button } from "@mui/material";
|
||||
import { Trans, withTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "../app/utils";
|
||||
|
||||
@@ -9,8 +8,7 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: false,
|
||||
originalStack: null,
|
||||
niceStack: null,
|
||||
stack: null,
|
||||
unsupportedIndexedDB: false,
|
||||
};
|
||||
}
|
||||
@@ -32,23 +30,17 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
}
|
||||
|
||||
handleError(error, info) {
|
||||
// Immediately render original stack trace
|
||||
const prettierOriginalStack = info.componentStack
|
||||
const componentStack = info.componentStack
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => ` at ${line}`)
|
||||
.join("\n");
|
||||
const parts = [error.toString()];
|
||||
if (error.stack) parts.push(error.stack);
|
||||
parts.push(componentStack);
|
||||
this.setState({
|
||||
error: true,
|
||||
originalStack: `${error.toString()}\n${prettierOriginalStack}`,
|
||||
});
|
||||
|
||||
// Fetch additional info and a better stack trace
|
||||
StackTrace.fromError(error).then((stack) => {
|
||||
console.error("[ErrorBoundary] Stacktrace fetched", stack);
|
||||
const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
|
||||
const niceStack = `${error.toString()}\n${stackString}`;
|
||||
this.setState({ niceStack });
|
||||
stack: parts.join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,12 +52,7 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
}
|
||||
|
||||
copyStack() {
|
||||
let stack = "";
|
||||
if (this.state.niceStack) {
|
||||
stack += `${this.state.niceStack}\n\n`;
|
||||
}
|
||||
stack += `${this.state.originalStack}\n`;
|
||||
copyToClipboard(stack);
|
||||
copyToClipboard(`${this.state.stack}\n`);
|
||||
}
|
||||
|
||||
renderUnsupportedIndexedDB() {
|
||||
@@ -112,14 +99,7 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
</Button>
|
||||
</div>
|
||||
<h3>{t("error_boundary_stack_trace")}</h3>
|
||||
{this.state.niceStack ? (
|
||||
<pre>{this.state.niceStack}</pre>
|
||||
) : (
|
||||
<>
|
||||
<CircularProgress size="20px" sx={{ verticalAlign: "text-bottom" }} /> {t("error_boundary_gathering_info")}
|
||||
</>
|
||||
)}
|
||||
<pre>{this.state.originalStack}</pre>
|
||||
<pre>{this.state.stack}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ const autolink = (s) => {
|
||||
const parts = s.split(/(\bhttps?:\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|]\b)/gi);
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
parts[i] = (
|
||||
<Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">
|
||||
<Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer">
|
||||
{shortUrl(parts[i])}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ import priority3 from "../img/priority-3.svg";
|
||||
import priority4 from "../img/priority-4.svg";
|
||||
import priority5 from "../img/priority-5.svg";
|
||||
import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
|
||||
import { imageRegex } from "../app/notificationUtils";
|
||||
import AttachmentIcon from "./AttachmentIcon";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import api from "../app/Api";
|
||||
@@ -805,7 +806,7 @@ const AttachmentBox = (props) => {
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<AttachmentIcon type={file.type} href={URL.createObjectURL(file)} />
|
||||
<AttachmentIcon type={file.type} href={imageRegex.test(file.name) ? URL.createObjectURL(file) : undefined} />
|
||||
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
|
||||
<ExpandingTextField
|
||||
minWidth={140}
|
||||
|
||||
@@ -13,7 +13,7 @@ import versionChecker from "../app/VersionChecker";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
import notifier from "../app/Notifier";
|
||||
import prefs from "../app/Prefs";
|
||||
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events";
|
||||
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR, SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG } from "../app/events";
|
||||
|
||||
/**
|
||||
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
||||
@@ -283,6 +283,52 @@ export const useStandaloneWebPushAutoSubscribe = () => {
|
||||
}, [isLaunchedPWA]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a periodicsync listener for `extend_token` (see sw.js).
|
||||
* This extends the token regardless of whether the browser is open.
|
||||
*
|
||||
* CAVEATS:
|
||||
* - Chromium-only
|
||||
* - Only when the PWA is _installed_ (not just running in a browser tab)
|
||||
* - Only when notifications are granted
|
||||
*
|
||||
* This is an experimental feature:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/Web_Periodic_Background_Synchronization_API
|
||||
*/
|
||||
const usePeriodicTokenExtend = () => {
|
||||
const isLaunchedPWA = useIsLaunchedPWA();
|
||||
const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!isLaunchedPWA) {
|
||||
console.debug("[usePeriodicTokenExtend] Skipping: Not running as PWA");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pushPossible) {
|
||||
console.debug("[usePeriodicTokenExtend] Skipping: Web push not possible or granted");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!registration.periodicSync) {
|
||||
console.debug("[usePeriodicTokenExtend] Skipping: Periodic Sync not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[usePeriodicTokenExtend] Turning on periodicsync "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`);
|
||||
await registration.periodicSync.register(SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG, {
|
||||
minInterval: 12 * 60 * 60 * 1000, // 12 hours
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("[usePeriodicTokenExtend] Periodic Sync could not be registered", error);
|
||||
}
|
||||
})();
|
||||
}, [isLaunchedPWA, pushPossible]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
|
||||
* and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
|
||||
@@ -305,6 +351,7 @@ const stopWorkers = () => {
|
||||
|
||||
export const useBackgroundProcesses = () => {
|
||||
useStandaloneWebPushAutoSubscribe();
|
||||
usePeriodicTokenExtend();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[useBackgroundProcesses] mounting");
|
||||
|
||||
Reference in New Issue
Block a user