Compare commits

...

58 Commits

Author SHA1 Message Date
arahja 1dc08ee0bd
Merge e5b2c37c44 into de8e3bc2aa 2025-06-06 10:26:19 +02:00
Philipp C. Heckel de8e3bc2aa
Merge pull request #1360 from binwiederhier/client-ip-header
Add custom client IP header
2025-06-01 10:12:34 -04:00
binwiederhier d3f7aa7008 Self-review 2025-06-01 10:12:06 -04:00
binwiederhier bbfaf2fc4d Add Forwarded header parsing 2025-06-01 09:57:39 -04:00
binwiederhier db4ac158e3 Section 2025-05-31 23:09:51 -04:00
binwiederhier 7a33e16945 Cleanup, examples 2025-05-31 23:07:40 -04:00
binwiederhier eac49feb04 Merge branch 'main' of github.com:binwiederhier/ntfy into client-ip-header 2025-05-31 22:42:40 -04:00
binwiederhier 849884c947 Change to "proxy-forwarded-header" and add "proxy-trusted-addrs" 2025-05-31 22:39:18 -04:00
binwiederhier 2cb4d089ab Merge client ip header 2025-05-31 15:33:21 -04:00
binwiederhier dc797f8594 Fix release noteFix release notess 2025-05-29 20:35:22 -04:00
binwiederhier 061677a78b Bump version 2025-05-29 20:14:54 -04:00
binwiederhier b4f15ec9d4 Integrations 2025-05-26 13:41:26 -04:00
binwiederhier af17661053 Typos, server.yml additions 2025-05-25 20:13:13 -04:00
binwiederhier 635ec88c4f Update release log 2025-05-25 15:28:59 -04:00
Philipp C. Heckel 905f048ab4
Merge pull request #1123 from stendler/json-firebase-cache
feat(server): add Cache and Firebase as keys to JSON publishing
2025-05-25 15:24:51 -04:00
binwiederhier 7f86108379 Update docs 2025-05-25 12:57:02 -04:00
Philipp C. Heckel 425e6d064e
Merge pull request #1002 from dandersch/ntfy-client_user_service
Add systemd user service for `ntfy-client.service`
2025-05-25 12:42:51 -04:00
Philipp C. Heckel ebb61fcccf
Merge pull request #1353 from binwiederhier/apns-fix
APNs fix
2025-05-25 12:33:53 -04:00
binwiederhier 9f72eb804d Computers are fast 2025-05-25 12:32:16 -04:00
binwiederhier 42af71e546 Fix test 2025-05-25 12:27:21 -04:00
binwiederhier df818cfebc Merge branch 'main' of github.com:binwiederhier/ntfy 2025-05-25 12:23:20 -04:00
binwiederhier 0de1990c01 Increase number of access tokens per user to 60 2025-05-25 12:23:02 -04:00
binwiederhier f40023aa23 APNs fix 2025-05-25 12:09:57 -04:00
Philipp C. Heckel 5765a707fc
Merge pull request #1352 from leukosaima/patch-1
Add ntfyrr project to integrations
2025-05-25 06:43:38 -04:00
leukosaima 5eb84f759b
Add ntfyrr project 2025-05-25 00:20:55 -04:00
binwiederhier df7dd9c498 Fix weebpush test 2025-05-24 15:55:02 -04:00
binwiederhier 6fe3913aee Increase Web Push expiration to 55/60 days, update configs 2025-05-24 15:26:25 -04:00
Philipp C. Heckel 0ad9716241
Merge pull request #1212 from KuroSetsuna29/config-webpush-expiry
feat: allow configurable web push expiry duration
2025-05-24 14:57:50 -04:00
binwiederhier f4c37ccfb9 Bump VIte 2025-05-24 14:22:02 -04:00
Philipp C. Heckel 7182d3a4e5
Merge pull request #1342 from binwiederhier/dependabot/npm_and_yarn/web/multi-4e779676ec
Bump esbuild, vite and vite-plugin-pwa in /web
2025-05-24 14:07:01 -04:00
binwiederhier eecd3245f0 Release notes 2025-05-24 09:36:16 -04:00
binwiederhier 4dc3b38c95 Allow adding/changing user with password hash via v1/users API 2025-05-24 09:31:57 -04:00
binwiederhier 9edab24d4c Merge branch 'main' of github.com:binwiederhier/ntfy 2025-05-24 09:10:07 -04:00
Philipp C. Heckel 3b627b27b3
Merge pull request #1340 from Tom-Hubrecht/hashed-pwd
user: Allow changing the hashed password directly
2025-05-24 09:10:02 -04:00
binwiederhier 80462f7ee5 Refine user API change 2025-05-24 08:58:44 -04:00
Philipp C. Heckel 65e377ec63
Merge pull request #1267 from wunter8/api-change-user-password
Api change user password
2025-05-24 08:53:28 -04:00
dandersch 45e1707d3b
remove systemd user daemon-reload from postinst.sh 2025-05-23 23:00:45 +02:00
dandersch 0581a9e680
remove systemd user service cmds from postinst.sh 2025-05-23 22:52:25 +02:00
Hunter Kehoe 0fb60ae72d test change user password and tier in single request 2025-05-22 20:01:50 -06:00
Hunter Kehoe e36e4856c9 allow changing password or tier with user PUT 2025-05-22 19:57:57 -06:00
Hunter Kehoe fa48639517 make POST create user and PUT update user 2025-05-22 19:57:02 -06:00
Hunter Kehoe 2b40ad9a12 make staticcheck happy 2025-05-22 19:57:02 -06:00
Hunter Kehoe ad7ab18fb7 prevent changing admin passwords 2025-05-22 19:57:02 -06:00
Hunter Kehoe 8f9dafce20 change user password via accounts API 2025-05-22 19:57:00 -06:00
dependabot[bot] 7aab7d387f
Bump esbuild, vite and vite-plugin-pwa in /web
Bumps [esbuild](https://github.com/evanw/esbuild) to 0.25.4 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa). These dependencies need to be updated together.


Updates `esbuild` from 0.18.20 to 0.25.4
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2023.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.18.20...v0.25.4)

Updates `vite` from 4.5.14 to 6.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.5/packages/vite)

Updates `vite-plugin-pwa` from 0.15.2 to 1.0.0
- [Release notes](https://github.com/vite-pwa/vite-plugin-pwa/releases)
- [Commits](https://github.com/vite-pwa/vite-plugin-pwa/compare/v0.15.2...v1.0.0)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.25.4
  dependency-type: indirect
- dependency-name: vite
  dependency-version: 6.3.5
  dependency-type: direct:development
- dependency-name: vite-plugin-pwa
  dependency-version: 1.0.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-22 00:04:33 +00:00
Tom Hubrecht 44b7c2f198
user: Allow changing the hashed password directly
This adds the detection of `NTFY_PASSWORD_HASH` when creating a user or
changing its passsword so that scripts don't have to manipulate the bare
password.
2025-05-21 16:34:14 +02:00
Kyle Duren a49cafbadb more correcting auto-formats 2025-01-06 02:55:03 +00:00
Kyle Duren 0aee6252bb fixing auto-format change 2025-01-06 01:09:40 +00:00
Kyle Duren 0e6a483b2f fixing auto-format change 2025-01-06 01:06:28 +00:00
Kyle Duren 20c014ba8d Adding test and some docs 2025-01-06 00:57:53 +00:00
Kyle Duren 926967b6e7 adding logic to specifcy client-ip header from proxy 2025-01-05 20:29:08 +00:00
KuroSetsuna29 136b656ccb fix descriptions 2024-11-02 08:50:57 -04:00
KuroSetsuna29 c844c24a16 allow configurable web push expiry duration 2024-11-02 00:46:31 -04:00
stendler 5211d06f2c feat(server): add Cache and Firebase as keys to JSON publishing
https://github.com/binwiederhier/ntfy/issues/1119
2024-05-29 21:23:06 +02:00
Adam Rahja e5b2c37c44 Removed ntfy user account from arm dockerfile. Unable to reproduce build issue locally on ubuntu 22.04 based server. 2024-05-08 12:40:28 -05:00
Adam Rahja 5d2351a13a Updated the ntfy user uuid to 10,000+ to follow best practices. 2024-03-29 08:37:48 -05:00
Adam Rahja 3de5a4f2a2 Reworked adding non-root user to docker images. This gives you the ability to run ntfy as a non-root user. fixes binwiederhier#966 2024-03-29 08:37:48 -05:00
da b6983e6866 add ntfy-client.service as user service 2024-01-15 22:06:46 +01:00
37 changed files with 1930 additions and 944 deletions

View File

@ -90,6 +90,8 @@ nfpms:
type: "config|noreplace"
- src: client/ntfy-client.service
dst: /lib/systemd/system/ntfy-client.service
- src: client/user/ntfy-client.service
dst: /lib/systemd/user/ntfy-client.service
- dst: /var/cache/ntfy
type: dir
- dst: /var/cache/ntfy/attachments
@ -119,6 +121,7 @@ archives:
- server/ntfy.service
- client/client.yml
- client/ntfy-client.service
- client/user/ntfy-client.service
-
id: ntfy_windows
builds:

View File

@ -9,7 +9,8 @@ LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
LABEL org.opencontainers.image.title="ntfy"
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
RUN apk add --no-cache tzdata
RUN apk add --no-cache tzdata \
&& /usr/sbin/adduser -D -u 10001 ntfy
COPY ntfy /usr/bin
EXPOSE 80/tcp

View File

@ -56,6 +56,8 @@ LABEL org.opencontainers.image.title="ntfy"
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
LABEL org.opencontainers.image.version="$VERSION"
RUN /usr/sbin/adduser -D -u 10001 ntfy
COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
EXPOSE 80/tcp

View File

@ -0,0 +1,10 @@
[Unit]
Description=ntfy client
After=network.target
[Service]
ExecStart=/usr/bin/ntfy subscribe --config "%h/.config/ntfy/client.yml" --from-config
Restart=on-failure
[Install]
WantedBy=default.target

View File

@ -88,7 +88,9 @@ var flagsServe = append(
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.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-addresses", Aliases: []string{"proxy_trusted_addresses"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_ADDRESSES"}, Value: "", Usage: "comma-separated list of trusted IP addresses to remove from forwarded header"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
@ -100,6 +102,8 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-duration", Aliases: []string{"web_push_expiry_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryDuration), Usage: "automatically expire unused subscriptions after this time"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-warning-duration", Aliases: []string{"web_push_expiry_warning_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryWarningDuration), Usage: "send web push warning notification after this time before expiring unused subscriptions"}),
)
var cmdServe = &cli.Command{
@ -140,6 +144,8 @@ func execServe(c *cli.Context) error {
webPushFile := c.String("web-push-file")
webPushEmailAddress := c.String("web-push-email-address")
webPushStartupQueries := c.String("web-push-startup-queries")
webPushExpiryDurationStr := c.String("web-push-expiry-duration")
webPushExpiryWarningDurationStr := c.String("web-push-expiry-warning-duration")
cacheFile := c.String("cache-file")
cacheDurationStr := c.String("cache-duration")
cacheStartupQueries := c.String("cache-startup-queries")
@ -186,6 +192,8 @@ func execServe(c *cli.Context) error {
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
behindProxy := c.Bool("behind-proxy")
proxyForwardedHeader := c.String("proxy-forwarded-header")
proxyTrustedAddresses := util.SplitNoEmpty(c.String("proxy-trusted-addresses"), ",")
stripeSecretKey := c.String("stripe-secret-key")
stripeWebhookKey := c.String("stripe-webhook-key")
billingContact := c.String("billing-contact")
@ -226,6 +234,14 @@ func execServe(c *cli.Context) error {
if err != nil {
return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr)
}
webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr)
if err != nil {
return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr)
}
webPushExpiryWarningDuration, err := util.ParseDuration(webPushExpiryWarningDurationStr)
if err != nil {
return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr)
}
// Convert sizes to bytes
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
@ -304,6 +320,10 @@ func execServe(c *cli.Context) error {
if messageSizeLimit > 5*1024*1024 {
return errors.New("message-size-limit cannot be higher than 5M")
}
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
} else if behindProxy && proxyForwardedHeader == "" {
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
}
// Backwards compatibility
@ -402,6 +422,8 @@ func execServe(c *cli.Context) error {
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
conf.BehindProxy = behindProxy
conf.ProxyForwardedHeader = proxyForwardedHeader
conf.ProxyTrustedAddresses = proxyTrustedAddresses
conf.StripeSecretKey = stripeSecretKey
conf.StripeWebhookKey = stripeWebhookKey
conf.BillingContact = billingContact
@ -417,6 +439,8 @@ func execServe(c *cli.Context) error {
conf.WebPushFile = webPushFile
conf.WebPushEmailAddress = webPushEmailAddress
conf.WebPushStartupQueries = webPushStartupQueries
conf.WebPushExpiryDuration = webPushExpiryDuration
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
// Set up hot-reloading of config
go sigHandlerConfigReload(config)

View File

@ -42,7 +42,7 @@ var cmdUser = &cli.Command{
Name: "add",
Aliases: []string{"a"},
Usage: "Adds a new user",
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME",
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD_HASH=... ntfy user add [--role=admin|user] USERNAME",
Action: execUserAdd,
Flags: []cli.Flag{
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
@ -55,12 +55,13 @@ granted otherwise by the auth-default-access setting). An admin user has read an
topics.
Examples:
ntfy user add phil # Add regular user phil
ntfy user add --role=admin phil # Add admin user phil
NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts)
ntfy user add phil # Add regular user phil
ntfy user add --role=admin phil # Add admin user phil
NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts)
NTFY_PASSWORD_HASH=... ntfy user add phil # Add user, using env variable to set password hash (for scripts)
You may set the NTFY_PASSWORD environment variable to pass the password. This is useful if
you are creating users via scripts.
You may set the NTFY_PASSWORD environment variable to pass the password, or NTFY_PASSWORD_HASH to pass
directly the bcrypt hash. This is useful if you are creating users via scripts.
`,
},
{
@ -79,7 +80,7 @@ Example:
Name: "change-pass",
Aliases: []string{"chp"},
Usage: "Changes a user's password",
UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME",
UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME\nNTFY_PASSWORD_HASH=... ntfy user change-pass USERNAME",
Action: execUserChangePass,
Description: `Change the password for the given user.
@ -89,9 +90,10 @@ it twice.
Example:
ntfy user change-pass phil
NTFY_PASSWORD=.. ntfy user change-pass phil
NTFY_PASSWORD_HASH=.. ntfy user change-pass phil
You may set the NTFY_PASSWORD environment variable to pass the new password. This is
useful if you are updating users via scripts.
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
directly the bcrypt hash. This is useful if you are updating users via scripts.
`,
},
@ -174,7 +176,12 @@ variable to pass the new password. This is useful if you are creating/updating u
func execUserAdd(c *cli.Context) error {
username := c.Args().Get(0)
role := user.Role(c.String("role"))
password := os.Getenv("NTFY_PASSWORD")
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
if !hashed {
password = os.Getenv("NTFY_PASSWORD")
}
if username == "" {
return errors.New("username expected, type 'ntfy user add --help' for help")
} else if username == userEveryone || username == user.Everyone {
@ -200,7 +207,7 @@ func execUserAdd(c *cli.Context) error {
}
password = p
}
if err := manager.AddUser(username, password, role); err != nil {
if err := manager.AddUser(username, password, role, hashed); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
@ -230,7 +237,11 @@ func execUserDel(c *cli.Context) error {
func execUserChangePass(c *cli.Context) error {
username := c.Args().Get(0)
password := os.Getenv("NTFY_PASSWORD")
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
if !hashed {
password = os.Getenv("NTFY_PASSWORD")
}
if username == "" {
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
} else if username == userEveryone || username == user.Everyone {
@ -249,7 +260,7 @@ func execUserChangePass(c *cli.Context) error {
return err
}
}
if err := manager.ChangePassword(username, password); err != nil {
if err := manager.ChangePassword(username, password, hashed); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)

View File

@ -295,7 +295,7 @@ want to use a dedicated token to publish from your backup host, and one from you
but not yet implemented.
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
automatically (or never expire). Each user can have up to 20 tokens (hardcoded).
automatically (or never expire). Each user can have up to 60 tokens (hardcoded).
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
```
@ -553,17 +553,69 @@ It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache),
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
Whatever your reasons may be, there are a few things to consider.
### IP-based rate limiting
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
[rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor,
as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
[rate limiting](#rate-limiting) logic to use the header configured in `proxy-forwarded-header` (default is `X-Forwarded-For`)
as the primary identifier for a visitor, as opposed to the remote IP address.
=== "/etc/ntfy/server.yml"
If the `behind-proxy` flag is not set, all visitors will be counted as one, because from the perspective of the
ntfy server, they all share the proxy's IP address.
Relevant flags to consider:
* `behind-proxy` makes it so that the real visitor IP address is extracted from the header defined in `proxy-forwarded-header`.
Without this, the remote address of the incoming connection is used (default: `false`).
* `proxy-forwarded-header` is the header to use to identify visitors (default: `X-Forwarded-For`). It may be a single IP address (e.g. `1.2.3.4`),
a comma-separated list of IP addresses (e.g. `1.2.3.4, 5.6.7.8`), or an [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)-style
header (e.g. `for=1.2.3.4;by=proxy.example.com, for=5.6.7.8`).
* `proxy-trusted-addresses` is a comma-separated list of IP addresses that are removed from the forwarded header
to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
the forwarded header (default: empty).
=== "/etc/ntfy/server.yml (behind a proxy)"
``` yaml
# Tell ntfy to use "X-Forwarded-For" to identify visitors
# Tell ntfy to use "X-Forwarded-For" header to identify visitors for rate limiting
#
# Example: If "X-Forwarded-For: 9.9.9.9, 1.2.3.4" is set,
# the visitor IP will be 1.2.3.4 (right-most address).
#
behind-proxy: true
```
=== "/etc/ntfy/server.yml (X-Client-IP header)"
``` yaml
# Tell ntfy to use "X-Client-IP" header to identify visitors for rate limiting
#
# Example: If "X-Client-IP: 9.9.9.9" is set,
# the visitor IP will be 9.9.9.9.
#
behind-proxy: true
proxy-forwarded-header: "X-Client-IP"
```
=== "/etc/ntfy/server.yml (Forwarded header)"
``` yaml
# Tell ntfy to use "Forwarded" header (RFC 7239) to identify visitors for rate limiting
#
# Example: If "Forwarded: for=1.2.3.4;by=proxy.example.com, for=9.9.9.9" is set,
# the visitor IP will be 9.9.9.9.
#
behind-proxy: true
proxy-forwarded-header: "Forwarded"
```
=== "/etc/ntfy/server.yml (multiple proxies)"
``` yaml
# Tell ntfy to use "X-Forwarded-For" header to identify visitors for rate limiting,
# and to strip the IP addresses of the proxies 1.2.3.4 and 1.2.3.5
#
# Example: If "X-Forwarded-For: 9.9.9.9, 1.2.3.4" is set,
# the visitor IP will be 9.9.9.9 (right-most unknown address).
#
behind-proxy: true
proxy-trusted-addresses: "1.2.3.4, 1.2.3.5"
```
### TLS/SSL
ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you
are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below).
@ -877,7 +929,9 @@ a database to keep track of the browser's subscriptions, and an admin email addr
- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`
- `web-push-startup-queries` is an optional list of queries to run on startup`
- `web-push-startup-queries` is an optional list of queries to run on startup`
- `web-push-expiry-warning-duration` defines the duration after which unused subscriptions are sent a warning (default is `55d`)
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
Limitations:
@ -904,8 +958,8 @@ web-push-file: /var/cache/ntfy/webpush.db
web-push-email-address: sysadmin@example.com
```
The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 7 days,
and will automatically expire after 9 days (not configurable). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 55 days,
and will automatically expire after 60 days (default). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
subscriptions are also removed automatically.
The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
@ -1380,7 +1434,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 |
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). |
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). |
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) |
@ -1388,7 +1442,9 @@ 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. If set, enables authentication and access control. 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`. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
| `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-addresses` | `NTFY_PROXY_TRUSTED_ADDRESSES` | *comma-separated list of IPs* | - | Comma-separated list of trusted IP addresses to remove from forwarded header |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
@ -1433,6 +1489,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions |
| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup |
| `web-push-expiry-duration` | `NTFY_WEB_PUSH_EXPIRY_DURATION` | *duration* | 60d | Web Push: Duration after which a subscription is considered stale and will be deleted. This is to prevent stale subscriptions. |
| `web-push-expiry-warning-duration` | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION` | *duration* | 55d | Web Push: Duration after which a warning is sent to subscribers that their subscription will expire soon. This is to prevent stale subscriptions. |
| `log-format` | `NTFY_LOG_FORMAT` | *string* | `text` | Defines the output format, can be text or json |
| `log-file` | `NTFY_LOG_FILE` | *string* | - | Defines the filename to write logs to. If this is not set, ntfy logs to stderr |
| `log-level` | `NTFY_LOG_LEVEL` | *string* | `info` | Defines the default log level, can be one of trace, debug, info, warn or error |
@ -1535,5 +1593,7 @@ OPTIONS:
--web-push-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE]
--web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
--web-push-startup-queries value, --web_push_startup_queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
--web-push-expiry-duration value, --web_push_expiry_duration value automatically expire unused subscriptions after this time (default: "60d") [$NTFY_WEB_PUSH_EXPIRY_DURATION]
--web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value send web push warning notification after this time before expiring unused subscriptions (default: "55d") [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION]
--help, -h show help
```

View File

@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_amd64.tar.gz
tar zxvf ntfy_2.11.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.11.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.11.0_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.tar.gz
tar zxvf ntfy_2.12.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.12.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_armv6.tar.gz
tar zxvf ntfy_2.11.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.11.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.11.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.tar.gz
tar zxvf ntfy_2.12.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.12.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_armv7.tar.gz
tar zxvf ntfy_2.11.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.11.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.11.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.tar.gz
tar zxvf ntfy_2.12.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.12.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_arm64.tar.gz
tar zxvf ntfy_2.11.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.11.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.11.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.tar.gz
tar zxvf ntfy_2.12.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.12.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@ -110,7 +110,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -118,7 +118,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -126,7 +126,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -134,7 +134,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -144,28 +144,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.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.11.0/ntfy_2.11.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.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.11.0/ntfy_2.11.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.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.11.0/ntfy_2.11.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## 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.11.0/ntfy_2.11.0_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.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.11.0/ntfy_2.11.0_darwin_all.tar.gz > ntfy_2.11.0_darwin_all.tar.gz
tar zxvf ntfy_2.11.0_darwin_all.tar.gz
sudo cp -a ntfy_2.11.0_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz > ntfy_2.12.0_darwin_all.tar.gz
tar zxvf ntfy_2.12.0_darwin_all.tar.gz
sudo cp -a ntfy_2.12.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.11.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.12.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@ -224,7 +224,7 @@ brew install ntfy
## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.11.0/ntfy_2.11.0_windows_amd64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).

View File

@ -40,12 +40,14 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [HetrixTools](https://docs.hetrixtools.com/ntfy-sh-notifications/) - Uptime monitoring
- [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage) - Visual data transformation and automation tool
- [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong.
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
## Integration via HTTP/SMTP/etc.
- [Watchtower](https://containrrr.dev/watchtower/) ⭐ - Automating Docker container base image updates (see [integration example](examples.md#watchtower-shoutrrr))
- [Jellyfin](https://jellyfin.org/) ⭐ - The Free Software Media System (see [integration example](examples.md#))
- [Overseer](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook))
- [Overseerr](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook))
- [Tautulli](https://github.com/Tautulli/Tautulli) ⭐ - Monitoring and tracking tool for Plex (integration [via webhook](https://github.com/Tautulli/Tautulli/wiki/Notification-Agents-Guide#webhook))
- [Mailrise](https://github.com/YoRyan/mailrise) - An SMTP gateway (integration via [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy))
- [Proxmox-Ntfy](https://github.com/qtsone/proxmox-ntfy) - Python script that monitors Proxmox tasks and sends notifications using the Ntfy service.
@ -83,7 +85,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
## CLIs + GUIs
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
- [ntfy Desktop client](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
- [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
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
@ -170,9 +173,19 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
## Blog + forum posts
- [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
- [How to Set Up Ntfy: Self-Hosted Push Notifications Made Easy](https://www.youtube.com/watch?v=wDJDiAYZ3H0) - youtube.com (sass drew) - 1/2025
- [The NTFY is a game-changer FREE solution for IT people](https://www.youtube.com/watch?v=NtlztHT-sRw) - youtube.com (Valters Tech Turf) - 1/2025
- [Notify: A Powerful Tool for Real-Time Notifications (ntfy.sh)](https://www.youtube.com/watch?v=XXTTeVfGBz0) - youtube.com (LinuxCloudHacks) - 12/2025
- [Push notifications with ntfy and n8n](https://www.youtube.com/watch?v=DKG1R3xYvwQ) - youtube.com (Oskar) - 10/2024
- [Setup ntfy for selfhosted notifications with Cloudflare Tunnel](https://medium.com/@svenvanginkel/setup-ntfy-for-selfhosted-notifications-with-cloudflare-tunnel-e342f470177d) - medium.com (Sven van Ginkel) - 10/2024
- [Self-Host NTFY - How It Works & Easy Setup Guide](https://www.youtube.com/watch?v=79wHc_jfrJE) ⭐ - youtube.com (Techdox)- 9/2024
- [ntfy / Emacs Lisp](https://speechcode.com/blog/ntfy/) - speechcode.com - 3/2024
- [Boost Your Productivity with ntfy.sh: The Ultimate Notification Tool for Command-Line Users](https://dev.to/archetypal/boost-your-productivity-with-ntfysh-the-ultimate-notification-tool-for-command-line-users-iil) - dev.to - 3/2024
- [Nextcloud Talk (F-Droid version) notifications using ntfy (ntfy.sh)](https://www.youtube.com/watch?v=0a6PpfN5PD8) - youtube.com - 2/2024

View File

@ -2,6 +2,66 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
### ntfy server v2.12.0
Released May 29, 2025
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
new features and bug fixes as well.
Thanks to everyone who contributed to this release, and special thanks to [@wunter8](https://github.com/wunter8) for his continued
user support in Discord/Matrix/GitHub! You rock, man!
**Features:**
* Add username/password auth to email publishing ([#1164](https://github.com/binwiederhier/ntfy/pull/1164), thanks to [@bishtawi](https://github.com/bishtawi))
* Write VAPID keys to file in `ntfy webpush --output-file` ([#1138](https://github.com/binwiederhier/ntfy/pull/1138), thanks to [@nogweii](https://github.com/nogweii))
* Add Docker major/minor version to image tags ([#1271](https://github.com/binwiederhier/ntfy/pull/1271), thanks to [@RoboMagus](https://github.com/RoboMagus))
* Add `latest` subscription param for grabbing just the most recent message ([#1216](https://github.com/binwiederhier/ntfy/pull/1216), thanks to [@wunter8](https://github.com/wunter8))
* Allow using `NTFY_PASSWORD_HASH` in `ntfy user` command instead of raw password ([#1340](https://github.com/binwiederhier/ntfy/pull/1340), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht) for implementing)
* You can now change passwords via `v1/users` API ([#1267](https://github.com/binwiederhier/ntfy/pull/1267), thanks to [@wunter8](https://github.com/wunter8) for implementing)
* Make WebPush subscription warning/expiry configurable, increase default to 55/60 days ([#1212](https://github.com/binwiederhier/ntfy/pull/1212), thanks to [@KuroSetsuna29](https://github.com/KuroSetsuna29))
* Support [systemd user service](https://docs.ntfy.sh/subscribe/cli/#using-the-systemd-service) `ntfy-client.service` ([#1002](https://github.com/binwiederhier/ntfy/pull/1002), thanks to [@dandersch](https://github.com/dandersch))
**Bug fixes + maintenance:**
* Security updates for dependencies and Docker images ([#1341](https://github.com/binwiederhier/ntfy/pull/1341))
* Upgrade to Vite 6 ([#1342](https://github.com/binwiederhier/ntfy/pull/1342), thanks Dependabot)
* Fix iOS delivery issues for read-protected topics ([#1207](https://github.com/binwiederhier/ntfy/pull/1287), thanks a lot to [@barart](https://github.com/barart)!)
* Add `Date` header to outgoing emails to avoid rejection ([#1141](https://github.com/binwiederhier/ntfy/pull/1141), thanks to [@pcouy](https://github.com/pcouy))
* Fix IP address parsing when behind a proxy ([#1266](https://github.com/binwiederhier/ntfy/pull/1266), thanks to [@mmatuska](https://github.com/mmatuska))
* Make sure UnifiedPush messages are not treated as attachments ([#1312](https://github.com/binwiederhier/ntfy/pull/1312), thanks to [@vkrause](https://github.com/vkrause))
* Add OCI image version to Docker image ([#1307](https://github.com/binwiederhier/ntfy/pull/1307), thanks to [@jlssmt](https://github.com/jlssmt))
* WebSocket returning incorrect HTTP error code ([#1338](https://github.com/binwiederhier/ntfy/pull/1338) / [#1337](https://github.com/binwiederhier/ntfy/pull/1337), thanks to [@wunter8](https://github.com/wunter8) for debugging and implementing)
* Make Markdown in the web app scrollable horizontally ([#1262](https://github.com/binwiederhier/ntfy/pull/1262), thanks to [@rake5k](https://github.com/rake5k) for fixing)
* Make sure WebPush subscription topics are actually deleted (no ticket)
* Increase the number of access tokens per user to 60 ([#1308](https://github.com/binwiederhier/ntfy/issues/1308))
* Allow specifying `cache` and `firebase` via JSON publishing ([#1119](https://github.com/binwiederhier/ntfy/issues/1119)/[#1123](https://github.com/binwiederhier/ntfy/pull/1123), thanks to [@stendler](https://github.com/stendler))
**Documentation:**
* Lots of new integrations and projects. Amazing!
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
* [UptimeObserver](https://uptimeobserver.com)
* [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay)
* [Monibot](https://monibot.io/)
* [Daily_Fact_Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy)
* [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage)
* [ntfy-run](https://github.com/quantum5/ntfy-run)
* [Clipboard IO](https://github.com/jim3692/clipboard-io)
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
* [InvaderInformant](https://github.com/patricksthannon/InvaderInformant)
* Various docs updates ([#1161](https://github.com/binwiederhier/ntfy/pull/1161), thanks to [@OneWeekNotice](https://github.com/OneWeekNotice))
* Typo in config docs ([#1177](https://github.com/binwiederhier/ntfy/pull/1177), thanks to [@hoho4190](https://github.com/hoho4190))
* Typo in CLI docs ([#1172](https://github.com/binwiederhier/ntfy/pull/1172), thanks to [@anirvan](https://github.com/anirvan))
* Correction about MacroDroid ([#1137](https://github.com/binwiederhier/ntfy/pull/1137), thanks to [@ShlomoCode](https://github.com/ShlomoCode))
* Note about fail2ban in Docker ([#1175](https://github.com/binwiederhier/ntfy/pull/1175)), thanks to [@Measurity](https://github.com/Measurity))
* Lots of other tiny docs updates, thanks to everyone who contributed!
**Languages**
* Update new languages from Weblate. Thanks to all the contributors!
* Added Tamil (தமிழ்) as a new language to the web app
### ntfy server v2.11.0
Released May 13, 2024
@ -1373,50 +1433,11 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.12.0 (UNRELEASED)
### ntfy server v2.13.0 (UNRELEASED)
**Features:**
* Add username/password auth to email publishing ([#1164](https://github.com/binwiederhier/ntfy/pull/1164), thanks to [@bishtawi](https://github.com/bishtawi))
* Write VAPID keys to file in `ntfy webpush --output-file` ([#1138](https://github.com/binwiederhier/ntfy/pull/1138), thanks to [@nogweii](https://github.com/nogweii))
* Add Docker major/minor version to image tags ([#1271](https://github.com/binwiederhier/ntfy/pull/1271), thanks to [@RoboMagus](https://github.com/RoboMagus))
* Add `latest` subscription param for grabbing just the most recent message ([#1216](https://github.com/binwiederhier/ntfy/pull/1216), thanks to [@wunter8](https://github.com/wunter8))
**Bug fixes + maintenance:**
* Security updates for dependencies and Docker images ([#1341](https://github.com/binwiederhier/ntfy/pull/1341))
* Fix iOS delivery issues for read-protected topics ([#1207](https://github.com/binwiederhier/ntfy/pull/1287), thanks a lot to [@barart](https://github.com/barart)!)
* Add `Date` header to outgoing emails to avoid rejection ([#1141](https://github.com/binwiederhier/ntfy/pull/1141), thanks to [@pcouy](https://github.com/pcouy))
* Fix IP address parsing when behind a proxy ([#1266](https://github.com/binwiederhier/ntfy/pull/1266), thanks to [@mmatuska](https://github.com/mmatuska))
* Make sure UnifiedPush messages are not treated as attachments ([#1312](https://github.com/binwiederhier/ntfy/pull/1312), thanks to [@vkrause](https://github.com/vkrause))
* Add OCI image version to Docker image ([#1307](https://github.com/binwiederhier/ntfy/pull/1307), thanks to [@jlssmt](https://github.com/jlssmt))
* WebSocket returning incorrect HTTP error code ([#1338](https://github.com/binwiederhier/ntfy/pull/1338) / [#1337](https://github.com/binwiederhier/ntfy/pull/1337), thanks to [@wunter8](https://github.com/wunter8) for debugging and implementing)
* Make Markdown in the web app scrollable horizontally ([#1262](https://github.com/binwiederhier/ntfy/pull/1262), thanks to [@rake5k](https://github.com/rake5k) for fixing)
**Documentation:**
* Lots of new integrations and projects. Amazing!
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
* [UptimeObserver](https://uptimeobserver.com)
* [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay)
* [Monibot](https://monibot.io/)
* [Daily_Fact_Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy)
* [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage)
* [ntfy-run](https://github.com/quantum5/ntfy-run)
* [Clipboard IO](https://github.com/jim3692/clipboard-io)
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
* [InvaderInformant](https://github.com/patricksthannon/InvaderInformant)
* Various docs updates ([#1161](https://github.com/binwiederhier/ntfy/pull/1161), thanks to [@OneWeekNotice](https://github.com/OneWeekNotice))
* Typo in config docs ([#1177](https://github.com/binwiederhier/ntfy/pull/1177), thanks to [@hoho4190](https://github.com/hoho4190))
* Typo in CLI docs ([#1172](https://github.com/binwiederhier/ntfy/pull/1172), thanks to [@anirvan](https://github.com/anirvan))
* Correction about MacroDroid ([#1137](https://github.com/binwiederhier/ntfy/pull/1137), thanks to [@ShlomoCode](https://github.com/ShlomoCode))
* Note about fail2ban in Docker ([#1175](https://github.com/binwiederhier/ntfy/pull/1175)), thanks to [@Measurity](https://github.com/Measurity))
* Lots of other tiny docs updates, tanks to everyone who contributed!
**Languages**
* Update new languages from Weblate. Thanks to all the contributors!
* Added Tamil (தமிழ்) as a new language to the web app
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-addresses` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
### ntfy Android app v1.16.1 (UNRELEASED)

View File

@ -190,6 +190,10 @@ Here's an example config file that subscribes to three different topics, executi
=== "~/.config/ntfy/client.yml (Linux)"
```yaml
default-host: https://ntfy.sh
default-user: phill
default-password: mypass
subscribe:
- topic: echo-this
command: 'echo "Message received: $message"'
@ -210,9 +214,12 @@ Here's an example config file that subscribes to three different topics, executi
fi
```
=== "~/Library/Application Support/ntfy/client.yml (macOS)"
```yaml
default-host: https://ntfy.sh
default-user: phill
default-password: mypass
subscribe:
- topic: echo-this
command: 'echo "Message received: $message"'
@ -226,6 +233,10 @@ Here's an example config file that subscribes to three different topics, executi
=== "%AppData%\ntfy\client.yml (Windows)"
```yaml
default-host: https://ntfy.sh
default-user: phill
default-password: mypass
subscribe:
- topic: echo-this
command: 'echo Message received: %message%'
@ -263,43 +274,31 @@ will be used, otherwise, the subscription settings will override the defaults.
require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
### Using the systemd service
You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started)
if you install the deb/rpm package. To configure it, simply edit `/etc/ntfy/client.yml` and run `sudo systemctl restart ntfy-client`.
You can use the `ntfy-client` systemd services to subscribe to multiple topics just like in the example above.
!!! info
The `ntfy-client.service` runs as user `ntfy`, meaning that typical Linux permission restrictions apply. See below
for how to fix this.
You have the option of either enabling `ntfy-client` as a **system service** (see [here](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
or **user service** (see [here](https://github.com/binwiederhier/ntfy/blob/main/client/user/ntfy-client.service)). Neither system service nor user service are enabled or started by default, so you have to do that yourself.
If the service runs on your personal desktop machine, you may want to override the service user/group (`User=` and `Group=`), and
adjust the `DISPLAY` and `DBUS_SESSION_BUS_ADDRESS` environment variables. This will allow you to run commands in your X session
as the primary machine user.
You can either manually override these systemd service entries with `sudo systemctl edit ntfy-client`, and add this
(assuming your user is `phil`). Don't forget to run `sudo systemctl daemon-reload` and `sudo systemctl restart ntfy-client`
after editing the service file:
=== "/etc/systemd/system/ntfy-client.service.d/override.conf"
```
[Service]
User=phil
Group=phil
Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"
```
Or you can run the following script that creates this override config for you:
**System service:** The `ntfy-client` systemd system service runs as the `ntfy` user. When enabled, it is started at system boot. To configure it as a system
service, edit `/etc/ntfy/client.yml` and then enable/start the service (as root), like so:
```
sudo sh -c 'cat > /etc/systemd/system/ntfy-client.service.d/override.conf' <<EOF
[Service]
User=$USER
Group=$USER
Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus"
EOF
sudo systemctl daemon-reload
sudo systemctl enable ntfy-client
sudo systemctl restart ntfy-client
```
The system service runs as user `ntfy`, meaning that typical Linux permission restrictions apply. It also means that the system service cannot run commands in your X session as the primary machine user (unlike the user service).
**User service:** The `ntfy-client` user service is run when the user logs into their desktop environment. To enable/start it, edit `~/.config/ntfy/client.yml` and
run the following commands (without sudo!):
```
systemctl --user enable ntfy-client
systemctl --user restart ntfy-client
```
Unlike the system service, the user service can interact with the user's desktop environment, and run commands like `notify-send` to display desktop notifications.
It can also run commands that require access to the user's home directory, such as `gnome-calculator`.
### Authentication
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics

30
go.mod
View File

@ -6,7 +6,7 @@ toolchain go1.24.0
require (
cloud.google.com/go/firestore v1.18.0 // indirect
cloud.google.com/go/storage v1.54.0 // indirect
cloud.google.com/go/storage v1.55.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.18.0
@ -21,7 +21,7 @@ require (
golang.org/x/sync v0.14.0
golang.org/x/term v0.32.0
golang.org/x/time v0.11.0
google.golang.org/api v0.234.0
google.golang.org/api v0.235.0
gopkg.in/yaml.v2 v2.4.0
)
@ -47,21 +47,21 @@ require (
cloud.google.com/go/longrunning v0.6.7 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-logr/logr v1.4.2 // 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
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
@ -73,7 +73,7 @@ require (
github.com/gorilla/css v1.0.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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.64.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
@ -83,9 +83,9 @@ require (
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
@ -95,10 +95,10 @@ require (
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/grpc v1.72.1 // indirect
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/grpc v1.72.2 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

67
go.sum
View File

@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFs
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI+0=
cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE=
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
firebase.google.com/go/v4 v4.15.2 h1:KJtV4rAfO2CVCp40hBfVk+mqUqg7+jQKx7yOgFDnXBg=
@ -28,14 +28,14 @@ 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.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0 h1:VaFXBL0NJpiFBtw4aVJpKHeKULVTcHpD+/G0ibZkcBw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0/go.mod h1:JXkPazkEc/dZTHzOlzv2vT1DlpWSTbSLmu/1KY6Ly0I=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 h1:QFgWzcdmJlgEAwJz/zePYVJQxfoJGRtgIqZfIUFg5oQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0/go.mod h1:ayYHuYU7iNcNtEs1K9k6D/Bju7u1VEHMQm5qQ1n3GtM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0 h1:0l8ynskVvq1dvIn5vJbFMf/a/3TqFpRmCMrruFbzlvk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0/go.mod h1:f/ad5NuHnYz8AOZGuR0cY+l36oSCstdxD73YlIchr6I=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 h1:wbMd4eG/fOhsCa6+IP8uEDvWF5vl7rNoUWmP5f72Tbs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0/go.mod h1:gdIm9TxRk5soClCwuB0FtdXsbqtw0aqPwBEurK9tPkw=
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=
@ -51,8 +51,8 @@ github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1Ig
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
@ -73,8 +73,8 @@ github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFA
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@ -124,8 +124,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@ -157,16 +158,16 @@ github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA=
go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
@ -258,18 +259,18 @@ 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=
google.golang.org/api v0.234.0 h1:d3sAmYq3E9gdr2mpmiWGbm9pHsA/KJmyiLkwKfHBqU4=
google.golang.org/api v0.234.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg=
google.golang.org/api v0.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo=
google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg=
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-20250519155744-55703ea1f237 h1:2zGWyk04EwQ3mmV4dd4M4U7P/igHi5p7CBJEg1rI6A8=
google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237/go.mod h1:LhI4bRmX3rqllzQ+BGneexULkEjBf2gsAfkbeCA8IbU=
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a h1:KXuwdBmgjb4T3l4ZzXhP6HxxFKXD9FcK5/8qfJI4WwU=
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a/go.mod h1:Nlk93rrS2X7rV8hiC2gh2A/AJspZhElz9Oh2KGsjLEY=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=

View File

@ -33,7 +33,7 @@ if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then
fi
fi
if systemctl is-active -q ntfy-client.service; then
echo "Restarting ntfy-client.service ..."
echo "Restarting ntfy-client.service (system) ..."
if [ -x /usr/bin/deb-systemd-invoke ]; then
deb-systemd-invoke try-restart ntfy-client.service >/dev/null || true
else

View File

@ -26,8 +26,8 @@ const (
// Defines default Web Push settings
const (
DefaultWebPushExpiryWarningDuration = 7 * 24 * time.Hour
DefaultWebPushExpiryDuration = 9 * 24 * time.Hour
DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour
DefaultWebPushExpiryDuration = 60 * 24 * time.Hour
)
// Defines all global and per-visitor limits
@ -143,7 +143,9 @@ type Config struct {
VisitorAuthFailureLimitReplenish time.Duration
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
BehindProxy bool
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For"
ProxyTrustedAddresses []string // List of trusted proxy addresses that will be stripped from the Forwarded header if BehindProxy is true
StripeSecretKey string
StripeWebhookKey string
StripePriceCacheDuration time.Duration
@ -232,7 +234,8 @@ func NewConfig() *Config {
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
VisitorSubscriberRateLimiting: false,
BehindProxy: false,
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
StripeSecretKey: "",
StripeWebhookKey: "",
StripePriceCacheDuration: DefaultStripePriceCacheDuration,

View File

@ -446,8 +446,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
} else if r.Method == http.MethodPost && r.URL.Path == apiUsersPath {
return s.ensureAdmin(s.handleUsersAdd)(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
return s.ensureAdmin(s.handleUsersUpdate)(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiUsersPath {
return s.ensureAdmin(s.handleUsersDelete)(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == apiUsersAccessPath {
@ -1866,6 +1868,12 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Call != "" {
r.Header.Set("X-Call", m.Call)
}
if m.Cache != "" {
r.Header.Set("X-Cache", m.Cache)
}
if m.Firebase != "" {
r.Header.Set("X-Firebase", m.Firebase)
}
return next(w, r, v)
}
}
@ -1928,8 +1936,8 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
// that subsequent logging calls still have a visitor context.
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
// Read "Authorization" header value, and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy)
// Read the "Authorization" header value and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses)
vip := s.visitor(ip, nil)
if s.userManager == nil {
return vip, nil
@ -2004,7 +2012,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
if err != nil {
return nil, err
}
ip := extractIPAddress(r, s.config.BehindProxy)
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses)
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
LastAccess: time.Now(),
LastOrigin: ip,

View File

@ -95,13 +95,23 @@
# auth-default-access: "read-write"
# auth-startup-queries:
# If set, the X-Forwarded-For header is used to determine the visitor IP address
# instead of the remote address of the connection.
# 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.
#
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate limited
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate-limited
# as if they are one.
#
# - behind-proxy makes it so that the real visitor IP address is extracted from the header defined in
# proxy-forwarded-header. Without this, the remote address of the incoming connection is used.
# - proxy-forwarded-header is the header to use to identify visitors. It may be a single IP address (e.g. 1.2.3.4),
# a comma-separated list of IP addresses (e.g. "1.2.3.4, 5.6.7.8"), or an RFC 7239-style header (e.g. "for=1.2.3.4;by=proxy.example.com, for=5.6.7.8").
# - proxy-trusted-addresses is a comma-separated list of IP addresses that are removed from the forwarded header
# to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
# the forwarded header.
#
# behind-proxy: false
# proxy-forwarded-header: "X-Forwarded-For"
# proxy-trusted-addresses:
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
# are "attachment-cache-dir" and "base-url".
@ -138,7 +148,7 @@
# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh
# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to "ntfy-",
# for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to
# $topic@ntfy.sh will be accepted (which may obviously be a spam problem).
# $topic@ntfy.sh will be accepted (which may be a spam problem).
#
# smtp-server-listen:
# smtp-server-domain:
@ -146,7 +156,7 @@
# Web Push support (background notifications for browsers)
#
# If enabled, allows ntfy to receive push notifications, even when the ntfy web app is closed. When enabled, users
# If enabled, allows the ntfy web app to receive push notifications, even when the web app is closed. When enabled, users
# can enable background notifications in the web app. Once enabled, ntfy will forward published messages to the push
# endpoint, which will then forward it to the browser.
#
@ -155,15 +165,19 @@
#
# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
# - web-push-email-address is the admin email address send to the push provider, e.g. `sysadmin@example.com`
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db
# - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com
# - web-push-startup-queries is an optional list of queries to run on startup`
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d`)
# - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d)
#
# web-push-public-key:
# web-push-private-key:
# web-push-file:
# web-push-email-address:
# web-push-startup-queries:
# web-push-expiry-warning-duration: "55d"
# web-push-expiry-duration: "60d"
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
#

View File

@ -37,7 +37,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
return errHTTPConflictUserExists
}
logvr(v, r).Tag(tagAccount).Field("user_name", newAccount.Username).Info("Creating user %s", newAccount.Username)
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil {
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, false); err != nil {
if errors.Is(err, user.ErrInvalidArgument) {
return errHTTPBadRequestInvalidUsername
}
@ -207,7 +207,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
return errHTTPBadRequestIncorrectPasswordConfirmation
}
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
if err := s.userManager.ChangePassword(u.Name, req.NewPassword); err != nil {
if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())

View File

@ -87,9 +87,9 @@ func TestAccount_Signup_AsUser(t *testing.T) {
defer s.closeDatabases()
log.Info("1")
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
log.Info("2")
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
log.Info("3")
rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
@ -174,7 +174,7 @@ func TestAccount_ChangeSettings(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, _ := s.userManager.User("phil")
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
@ -203,7 +203,7 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
@ -254,7 +254,7 @@ func TestAccount_ChangePassword(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": ""}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
@ -296,7 +296,7 @@ func TestAccount_ExtendToken(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
@ -332,7 +332,7 @@ func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
@ -345,7 +345,7 @@ func TestAccount_DeleteToken(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
@ -455,14 +455,14 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
Code: "pro",
ReservationLimit: 2,
}))
require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser))
require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro"))
require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll))
require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser))
require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro"))
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin, false))
// Admin can reserve topic
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{
@ -624,7 +624,7 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
s := newTestServer(t, conf)
// Create user with tier
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 20,

View File

@ -39,11 +39,11 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
}
func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false)
req, err := readJSONWithLimit[apiUserAddOrUpdateRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !user.AllowedUsername(req.Username) || req.Password == "" {
return errHTTPBadRequest.Wrap("username invalid, or password missing")
} else if !user.AllowedUsername(req.Username) || (req.Password == "" && req.Hash == "") {
return errHTTPBadRequest.Wrap("username invalid, or password/password_hash missing")
}
u, err := s.userManager.User(req.Username)
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
@ -60,7 +60,11 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
return err
}
}
if err := s.userManager.AddUser(req.Username, req.Password, user.RoleUser); err != nil {
password, hashed := req.Password, false
if req.Hash != "" {
password, hashed = req.Hash, true
}
if err := s.userManager.AddUser(req.Username, password, user.RoleUser, hashed); err != nil {
return err
}
if tier != nil {
@ -71,6 +75,53 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleUsersUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiUserAddOrUpdateRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !user.AllowedUsername(req.Username) {
return errHTTPBadRequest.Wrap("username invalid")
} else if req.Password == "" && req.Hash == "" && req.Tier == "" {
return errHTTPBadRequest.Wrap("need to provide at least one of \"password\", \"password_hash\" or \"tier\"")
}
u, err := s.userManager.User(req.Username)
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
return err
} else if u != nil {
if u.IsAdmin() {
return errHTTPForbidden
}
if req.Hash != "" {
if err := s.userManager.ChangePassword(req.Username, req.Hash, true); err != nil {
return err
}
} else if req.Password != "" {
if err := s.userManager.ChangePassword(req.Username, req.Password, false); err != nil {
return err
}
}
} else {
password, hashed := req.Password, false
if req.Hash != "" {
password, hashed = req.Hash, true
}
if err := s.userManager.AddUser(req.Username, password, user.RoleUser, hashed); err != nil {
return err
}
}
if req.Tier != "" {
if _, err = s.userManager.Tier(req.Tier); errors.Is(err, user.ErrTierNotFound) {
return errHTTPBadRequestTierInvalid
} else if err != nil {
return err
}
if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {
return err
}
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {

View File

@ -14,13 +14,13 @@ func TestUser_AddRemove(t *testing.T) {
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
// Create user via API
rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
@ -49,6 +49,226 @@ func TestUser_AddRemove(t *testing.T) {
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check user was deleted
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "emma", users[1].Name)
require.Equal(t, user.Everyone, users[2].Name)
// Reject invalid user change
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
}
func TestUser_AddWithPasswordHash(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check that user can login with password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, user.RoleAdmin, users[0].Role)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
}
func TestUser_ChangeUserPassword(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with first password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Change password via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Make sure first password fails
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben-two"),
})
require.Equal(t, 200, rr.Code)
}
func TestUser_ChangeUserTier(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier2",
}))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Equal(t, "tier1", users[1].Tier.Code)
// Change user tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users again
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, "tier2", users[1].Tier.Code)
}
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier2",
}))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Equal(t, "tier1", users[1].Tier.Code)
// Change user password and tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Make sure first password fails
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben-two"),
})
require.Equal(t, 200, rr.Code)
// Check new tier
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, "tier2", users[1].Tier.Code)
}
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with first password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "not-ben"),
})
require.Equal(t, 200, rr.Code)
// Change user password and tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
}
func TestUser_DontChangeAdminPassword(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
// Try to change password via API
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 403, rr.Code)
}
func TestUser_AddRemove_Failures(t *testing.T) {
@ -56,23 +276,23 @@ func TestUser_AddRemove_Failures(t *testing.T) {
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Cannot create user with invalid username
rr := request(t, s, "PUT", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
// Cannot create user if user already exists
rr = request(t, s, "PUT", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
// Cannot create user with invalid tier
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
@ -97,8 +317,8 @@ func TestAccess_AllowReset(t *testing.T) {
defer s.closeDatabases()
// User and admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Subscribing not allowed
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
@ -138,7 +358,7 @@ func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
defer s.closeDatabases()
// User
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Grant access fails, because non-admin
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
@ -154,8 +374,8 @@ func TestAccess_AllowReset_KillConnection(t *testing.T) {
defer s.closeDatabases()
// User and admin, grant access to "gol*" topics
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
start, timeTaken := time.Now(), atomic.Int64{}

View File

@ -138,41 +138,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
}
apnsConfig = createAPNSAlertConfig(m, data)
case messageEvent:
allowForward := true
if auther != nil {
allowForward = auther.Authorize(nil, m.Topic, user.PermissionRead) == nil
}
if allowForward {
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"click": m.Click,
"icon": m.Icon,
"title": m.Title,
"message": m.Message,
"content_type": m.ContentType,
"encoding": m.Encoding,
}
if len(m.Actions) > 0 {
actions, err := json.Marshal(m.Actions)
if err != nil {
return nil, err
}
data["actions"] = string(actions)
}
if m.Attachment != nil {
data["attachment_name"] = m.Attachment.Name
data["attachment_type"] = m.Attachment.Type
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
data["attachment_url"] = m.Attachment.URL
}
apnsConfig = createAPNSAlertConfig(m, data)
} else {
// If "anonymous read" for a topic is not allowed, we cannot send the message along
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
//
@ -180,23 +146,42 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
// fields are set, the iOS app fails to decode the message.
//
// See https://github.com/binwiederhier/ntfy/pull/1345
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": pollRequestEvent,
"topic": m.Topic,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": "",
"click": "",
"icon": "",
"title": "",
"message": newMessageBody,
"content_type": m.ContentType,
"encoding": m.Encoding,
"poll_id": m.ID,
if err := auther.Authorize(nil, m.Topic, user.PermissionRead); err != nil {
m = toPollRequest(m)
}
apnsConfig = createAPNSAlertConfig(m, data)
}
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"click": m.Click,
"icon": m.Icon,
"title": m.Title,
"message": m.Message,
"content_type": m.ContentType,
"encoding": m.Encoding,
}
if len(m.Actions) > 0 {
actions, err := json.Marshal(m.Actions)
if err != nil {
return nil, err
}
data["actions"] = string(actions)
}
if m.Attachment != nil {
data["attachment_name"] = m.Attachment.Name
data["attachment_type"] = m.Attachment.Type
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
data["attachment_url"] = m.Attachment.URL
}
if m.PollID != "" {
data["poll_id"] = m.PollID
}
apnsConfig = createAPNSAlertConfig(m, data)
}
var androidConfig *messaging.AndroidConfig
if m.Priority >= 4 {
@ -290,3 +275,17 @@ func maybeTruncateAPNSBodyMessage(s string) string {
}
return s
}
// toPollRequest converts a message to a poll request message.
//
// This empties all the fields that are not needed for a poll request and just sets the required fields,
// most importantly, the PollID.
func toPollRequest(m *message) *message {
pr := newPollRequestMessage(m.Topic, m.ID)
pr.ID = m.ID
pr.Time = m.Time
pr.Priority = m.Priority // Keep priority
pr.ContentType = m.ContentType
pr.Encoding = m.Encoding
return pr
}

View File

@ -240,6 +240,8 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
"content_type": "",
"poll_id": m.ID,
}, fbm.Data)
require.Equal(t, "", fbm.APNS.Payload.Aps.Alert.Title)
require.Equal(t, "New message", fbm.APNS.Payload.Aps.Alert.Body)
}
func TestToFirebaseMessage_PollRequest(t *testing.T) {

View File

@ -148,7 +148,7 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
Code: "pro",
StripeMonthlyPriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
// Create subscription
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
@ -184,7 +184,7 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
Code: "pro",
StripeMonthlyPriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
@ -226,7 +226,7 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
Code: "pro",
StripeMonthlyPriceID: "price_123",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
@ -280,7 +280,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in
MessageExpiryDuration: time.Hour,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) // No tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) // No tier
u, err := s.userManager.User("phil")
require.Nil(t, err)
@ -461,7 +461,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
AttachmentTotalSizeLimit: 1000000,
AttachmentBandwidthLimit: 1000000,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll))
@ -570,7 +570,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
StripeMonthlyPriceID: "price_1234",
ReservationLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
@ -658,7 +658,7 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
StripeMonthlyPriceID: "price_456",
StripeYearlyPriceID: "price_457",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
StripeCustomerID: "acct_123",
@ -690,7 +690,7 @@ func TestPayments_Subscription_Delete_At_Period_End(t *testing.T) {
Return(&stripe.Subscription{}, nil)
// Create user
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123",
@ -724,7 +724,7 @@ func TestPayments_CreatePortalSession(t *testing.T) {
}, nil)
// Create user
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123",

View File

@ -84,6 +84,22 @@ func TestServer_PublishWithFirebase(t *testing.T) {
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"])
}
func TestServer_PublishWithoutFirebase(t *testing.T) {
sender := newTestFirebaseSender(10)
s := newTestServer(t, newTestConfig(t))
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
response := request(t, s, "PUT", "/mytopic", "my first message", map[string]string{
"firebase": "no",
})
msg1 := toMessage(t, response.Body.String())
require.NotEmpty(t, msg1.ID)
require.Equal(t, "my first message", msg1.Message)
time.Sleep(100 * time.Millisecond) // Firebase publishing happens
require.Equal(t, 0, len(sender.Messages()))
}
func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) {
// This tests issue #641, which used to panic before the fix
@ -411,7 +427,7 @@ func TestServer_PublishAt_FromUser(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfigWithAuthFile(t))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
"In": "1h",
@ -786,7 +802,7 @@ func TestServer_Auth_Success_Admin(t *testing.T) {
c := newTestConfigWithAuthFile(t)
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
@ -800,7 +816,7 @@ func TestServer_Auth_Success_User(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
@ -814,7 +830,7 @@ func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite))
@ -835,7 +851,7 @@ func TestServer_Auth_Fail_InvalidPass(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
"Authorization": util.BasicAuth("phil", "INVALID"),
@ -848,7 +864,7 @@ func TestServer_Auth_Fail_Unauthorized(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic!
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
@ -862,7 +878,7 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
c.AuthDefault = user.PermissionReadWrite // Open by default
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
@ -911,7 +927,7 @@ func TestServer_Auth_ViaQuery(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin))
require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin, false))
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass"))))
response := request(t, s, "GET", u, "", nil)
@ -959,8 +975,8 @@ func TestServer_StatsResetter(t *testing.T) {
MessageLimit: 5,
MessageExpiryDuration: -5 * time.Second, // Second, what a hack!
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("tieruser", "tieruser", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.AddUser("tieruser", "tieruser", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("tieruser", "test"))
// Send an anonymous message
@ -1104,7 +1120,7 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
u, err := s.userManager.User("phil")
@ -1684,6 +1700,35 @@ func TestServer_PublishAsJSON_WithActions(t *testing.T) {
require.Equal(t, "target_temp_f=65", m.Actions[1].Body)
}
func TestServer_PublishAsJSON_NoCache(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := `{"topic":"mytopic","message": "this message is not cached","cache":"no"}`
response := request(t, s, "PUT", "/", body, nil)
msg := toMessage(t, response.Body.String())
require.NotEmpty(t, msg.ID)
require.Equal(t, "this message is not cached", msg.Message)
require.Equal(t, int64(0), msg.Expires)
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
messages := toMessages(t, response.Body.String())
require.Empty(t, messages)
}
func TestServer_PublishAsJSON_WithoutFirebase(t *testing.T) {
sender := newTestFirebaseSender(10)
s := newTestServer(t, newTestConfig(t))
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
body := `{"topic":"mytopic","message": "my first message","firebase":"no"}`
response := request(t, s, "PUT", "/", body, nil)
msg1 := toMessage(t, response.Body.String())
require.NotEmpty(t, msg1.ID)
require.Equal(t, "my first message", msg1.Message)
time.Sleep(100 * time.Millisecond) // Firebase publishing happens
require.Equal(t, 0, len(sender.Messages()))
}
func TestServer_PublishAsJSON_Invalid(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := `{"topic":"mytopic",INVALID`
@ -1701,7 +1746,7 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
MessageLimit: 5,
MessageExpiryDuration: -5 * time.Second, // Second, what a hack!
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish to reach message limit
@ -1937,7 +1982,7 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
AttachmentExpiryDuration: sevenDays, // 7 days
AttachmentBandwidthLimit: 100000,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish and make sure we can retrieve it
@ -1982,7 +2027,7 @@ func TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) {
AttachmentExpiryDuration: time.Hour,
AttachmentBandwidthLimit: 14000, // < 3x5000 bytes -> enough for one upload, one download
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish and make sure we can retrieve it
@ -2020,7 +2065,7 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
AttachmentExpiryDuration: 30 * time.Second,
AttachmentBandwidthLimit: 1000000,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish small file as anonymous
@ -2155,7 +2200,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
c.BehindProxy = true
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11"
r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
@ -2167,7 +2212,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
c.BehindProxy = true
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11"
r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", "1.1.1.1")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
@ -2179,13 +2224,40 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
c.BehindProxy = true
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11"
r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "234.5.2.1", v.ip.String())
}
func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "X-Client-IP"
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Client-IP", "1.2.3.4")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "1.2.3.4", v.ip.String())
}
func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "Forwarded"
c.ProxyTrustedAddresses = []string{"1.2.3.4"}
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "5.6.7.8", v.ip.String())
}
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
t.Parallel()
count := 50000
@ -2242,7 +2314,7 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {
defer s.closeDatabases()
// Create user without tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
// Publish a message (anonymous user)
rr := request(t, s, "POST", "/mytopic", "hi", nil)
@ -2275,7 +2347,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
// "Register" visitor 1.2.3.4 to topic "upAAAAAAAAAAAA" as a rate limit visitor
subscriber1Fn := func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
r.RemoteAddr = "1.2.3.4:1234"
}
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriber1Fn)
require.Equal(t, 200, rr.Code)
@ -2284,7 +2356,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
subscriber2Fn := func(r *http.Request) {
r.RemoteAddr = "8.7.7.1"
r.RemoteAddr = "8.7.7.1:1234"
}
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
require.Equal(t, 200, rr.Code)
@ -2327,7 +2399,7 @@ func TestServer_SubscriberRateLimiting_NotWrongTopic(t *testing.T) {
s := newTestServer(t, c)
subscriberFn := func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
r.RemoteAddr = "1.2.3.4:1234"
}
rr := request(t, s, "GET", "/alerts,upAAAAAAAAAAAA,upBBBBBBBBBBBB/json?poll=1", "", nil, subscriberFn)
require.Equal(t, 200, rr.Code)
@ -2347,7 +2419,7 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
// Registering visitor 1.2.3.4 to topic has no effect
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
r.RemoteAddr = "1.2.3.4:1234"
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String())
@ -2355,7 +2427,7 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
// Registering visitor 8.7.7.1 to topic has no effect
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) {
r.RemoteAddr = "8.7.7.1"
r.RemoteAddr = "8.7.7.1:1234"
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String())
@ -2381,7 +2453,7 @@ func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
// "Register" 5 different UnifiedPush visitors
for i := 0; i < 5; i++ {
subscriberFn := func(r *http.Request) {
r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1)
r.RemoteAddr = fmt.Sprintf("1.2.3.%d:1234", i+1)
}
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn)
require.Equal(t, 200, rr.Code)
@ -2405,7 +2477,7 @@ func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
// "Register" 5 different UnifiedPush visitors
for i := 0; i < 5; i++ {
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) {
r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1)
r.RemoteAddr = fmt.Sprintf("1.2.3.%d:1234", i+1)
})
require.Equal(t, 200, rr.Code)
}
@ -2432,7 +2504,7 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
// "Register" rate visitor
subscriberFn := func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
r.RemoteAddr = "1.2.3.4:1234"
}
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriberFn)
require.Equal(t, 200, rr.Code)
@ -2471,7 +2543,7 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *t
// - "up123456789012": Allowed, because no ACLs and nobody owns the topic
// - "announcements": NOT allowed, because it has read-only permissions for everyone
rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
r.RemoteAddr = "1.2.3.4:1234"
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String())
@ -2913,7 +2985,7 @@ func request(t *testing.T, s *Server, method, url, body string, headers map[stri
if err != nil {
t.Fatal(err)
}
r.RemoteAddr = "9.9.9.9" // Used for tests
r.RemoteAddr = "9.9.9.9:1234" // Used for tests
for k, v := range headers {
r.Header.Set(k, v)
}

View File

@ -63,7 +63,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
@ -140,7 +140,7 @@ func TestServer_Twilio_Call_Success(t *testing.T) {
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
@ -185,7 +185,7 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
@ -216,7 +216,7 @@ func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Do the thing

View File

@ -96,7 +96,7 @@ func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
@ -126,7 +126,7 @@ func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
s := newTestServer(t, config)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
@ -212,7 +212,7 @@ func TestServer_WebPush_Expiry(t *testing.T) {
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-7*24*time.Hour).Unix())
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix())
require.Nil(t, err)
s.pruneAndNotifyWebPushSubscriptions()
@ -222,7 +222,7 @@ func TestServer_WebPush_Expiry(t *testing.T) {
return received.Load()
})
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-9*24*time.Hour).Unix())
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix())
require.Nil(t, err)
s.pruneAndNotifyWebPushSubscriptions()

View File

@ -105,6 +105,8 @@ type publishMessage struct {
Filename string `json:"filename"`
Email string `json:"email"`
Call string `json:"call"`
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
Delay string `json:"delay"`
}
@ -253,9 +255,10 @@ type apiStatsResponse struct {
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
}
type apiUserAddRequest struct {
type apiUserAddOrUpdateRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Hash string `json:"hash"`
Tier string `json:"tier"`
// Do not add 'role' here. We don't want to add admins via the API.
}

View File

@ -10,12 +10,18 @@ import (
"net/http"
"net/netip"
"regexp"
"slices"
"strings"
)
var (
mimeDecoder mime.WordDecoder
mimeDecoder mime.WordDecoder
// priorityHeaderIgnoreRegex matches specific patterns of the "Priority" header (RFC 9218), so that it can be ignored
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
// forwardedHeaderRegex parses IPv4 addresses from the "Forwarded" header (RFC 7239)
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"?`)
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
@ -34,15 +40,11 @@ func toBool(value string) bool {
return value == "1" || value == "yes" || value == "true"
}
func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) {
paramStr := readParam(r, names...)
if paramStr != "" {
params = make([]string, 0)
for _, s := range util.SplitNoEmpty(paramStr, ",") {
params = append(params, strings.TrimSpace(s))
}
func readCommaSeparatedParam(r *http.Request, names ...string) []string {
if paramStr := readParam(r, names...); paramStr != "" {
return util.Map(util.SplitNoEmpty(paramStr, ","), strings.TrimSpace)
}
return params
return []string{}
}
func readParam(r *http.Request, names ...string) string {
@ -73,34 +75,58 @@ func readQueryParam(r *http.Request, names ...string) string {
return ""
}
func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
remoteAddr := r.RemoteAddr
addrPort, err := netip.ParseAddrPort(remoteAddr)
ip := addrPort.Addr()
// extractIPAddress extracts the IP address of the visitor from the request,
// either from the TCP socket or from a proxy header.
func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedAddresses []string) netip.Addr {
if behindProxy && proxyForwardedHeader != "" {
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedAddresses); err == nil {
return addr
}
// Fall back to the remote address if the header is not found or invalid
}
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
// This should not happen in real life; only in tests. So, using falling back to 0.0.0.0 if address unspecified
ip, err = netip.ParseAddr(remoteAddr)
if err != nil {
ip = netip.IPv4Unspecified()
if remoteAddr != "@" && !behindProxy { // RemoteAddr is @ when unix socket is used
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", remoteAddr)
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", r.RemoteAddr)
return netip.IPv4Unspecified()
}
return addrPort.Addr()
}
// extractIPAddressFromHeader extracts the last IP address from the specified header.
//
// It supports multiple formats:
// - single IP address
// - comma-separated list
// - RFC 7239-style list (Forwarded header)
//
// If there are multiple addresses, we first remove the trusted IP addresses from the list, and
// then take the right-most address in the list (as this is the one added by our proxy server).
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedAddresses []string) (netip.Addr, error) {
value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader)))
if value == "" {
return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader)
}
// Extract valid addresses
addrsStrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace)
var validAddrs []netip.Addr
for _, addrStr := range addrsStrs {
if addr, err := netip.ParseAddr(addrStr); err == nil {
validAddrs = append(validAddrs, addr)
} else if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
if addr, err := netip.ParseAddr(m[1]); err == nil {
validAddrs = append(validAddrs, addr)
}
}
}
if behindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" {
// X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy,
// only the right-most address can be trusted (as this is the one added by our proxy server).
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
if err != nil {
logr(r).Err(err).Error("invalid IP address %s received in X-Forwarded-For header", ip)
// Fall back to regular remote address if X-Forwarded-For is damaged
} else {
ip = realIP
}
// Filter out proxy addresses
clientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool {
return !slices.Contains(trustedAddresses, addr.String())
})
if len(clientAddrs) == 0 {
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)
}
return ip
return clientAddrs[len(clientAddrs)-1], nil
}
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
@ -133,7 +159,7 @@ func fromContext[T any](r *http.Request, key contextKey) (T, error) {
// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
// to ignore new HTTP "Priority" header.
// to ignore the new HTTP "Priority" header.
func maybeDecodeHeader(name, value string) string {
decoded, err := mimeDecoder.DecodeHeader(value)
if err != nil {
@ -142,7 +168,7 @@ func maybeDecodeHeader(name, value string) string {
return maybeIgnoreSpecialHeader(name, decoded)
}
// maybeIgnoreSpecialHeader ignores new HTTP "Priority" header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
// maybeIgnoreSpecialHeader ignores the new HTTP "Priority" header (RFC 9218, see https://datatracker.ietf.org/doc/html/rfc9218)
//
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.

View File

@ -88,3 +88,33 @@ func TestMaybeDecodeHeaders(t *testing.T) {
r.Header.Set("X-Priority", "5") // ntfy priority header
require.Equal(t, "5", readHeaderParam(r, "x-priority", "priority", "p"))
}
func TestExtractIPAddress(t *testing.T) {
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
r.RemoteAddr = "10.0.0.1:1234"
r.Header.Set("X-Forwarded-For", " 1.2.3.4 , 5.6.7.8")
r.Header.Set("X-Client-IP", "9.10.11.12")
r.Header.Set("X-Real-IP", "13.14.15.16, 1.1.1.1")
r.Header.Set("Forwarded", "for=17.18.19.20;by=proxy.example.com, by=2.2.2.2;for=1.1.1.1")
trustedProxies := []string{"1.1.1.1"}
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
require.Equal(t, "9.10.11.12", extractIPAddress(r, true, "X-Client-IP", trustedProxies).String())
require.Equal(t, "13.14.15.16", extractIPAddress(r, true, "X-Real-IP", trustedProxies).String())
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
require.Equal(t, "10.0.0.1", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
}
func TestExtractIPAddress_UnixSocket(t *testing.T) {
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
r.RemoteAddr = "@"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 1.1.1.1")
r.Header.Set("Forwarded", "by=bla.example.com;for=17.18.19.20")
trustedProxies := []string{"1.1.1.1"}
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
require.Equal(t, "0.0.0.0", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
}

View File

@ -79,8 +79,9 @@ const (
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
deleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`
)
// Schema management queries
@ -271,6 +272,10 @@ func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error {
// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
_, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
if err != nil {
return err
}
_, err = c.db.Exec(deleteWebPushSubscriptionTopicWithoutSubscription)
return err
}

View File

@ -28,7 +28,7 @@ const (
userHardDeleteAfterDuration = 7 * 24 * time.Hour
tokenPrefix = "tk_"
tokenLength = 32
tokenMaxCount = 20 // Only keep this many tokens in the table per user
tokenMaxCount = 60 // Only keep this many tokens in the table per user
tag = "user_manager"
)
@ -864,13 +864,19 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
}
// AddUser adds a user with the given username, password and role
func (a *Manager) AddUser(username, password string, role Role) error {
func (a *Manager) AddUser(username, password string, role Role, hashed bool) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
if err != nil {
return err
var hash []byte
var err error = nil
if hashed {
hash = []byte(password)
} else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
if err != nil {
return err
}
}
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
@ -1192,10 +1198,17 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
}
// ChangePassword changes a user's password
func (a *Manager) ChangePassword(username, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
if err != nil {
return err
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
var hash []byte
var err error
if hashed {
hash = []byte(password)
} else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
if err != nil {
return err
}
}
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
return err

View File

@ -14,13 +14,13 @@ import (
"time"
)
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
const minBcryptTimingMillis = int64(40) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("john", "john", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.AddUser("john", "john", RoleUser, false))
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
@ -134,7 +134,7 @@ func TestManager_Access_Order_LengthWriteRead(t *testing.T) {
// and longer ACL rules are prioritized as well.
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.AllowAccess("ben", "test*", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "*", PermissionRead))
@ -147,20 +147,20 @@ func TestManager_Access_Order_LengthWriteRead(t *testing.T) {
func TestManager_AddUser_Invalid(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin))
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role"))
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, false))
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", false))
}
func TestManager_AddUser_Timing(t *testing.T) {
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
start := time.Now().UnixMilli()
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
}
func TestManager_AddUser_And_Query(t *testing.T) {
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
require.Nil(t, a.ChangeBilling("user", &Billing{
StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123",
@ -187,7 +187,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
// Create user, add reservations and token
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead))
u, err := a.User("user")
@ -237,7 +237,7 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
// Create user, add reservations and token
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
u, err := a.User("user")
require.Nil(t, err)
@ -248,8 +248,8 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
func TestManager_UserManagement(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
@ -339,21 +339,31 @@ func TestManager_UserManagement(t *testing.T) {
func TestManager_ChangePassword(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
require.Nil(t, a.AddUser("jane", "$2b$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
_, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
require.Nil(t, a.ChangePassword("phil", "newpass"))
_, err = a.Authenticate("jane", "jane")
require.Nil(t, err)
require.Nil(t, a.ChangePassword("phil", "newpass", false))
_, err = a.Authenticate("phil", "phil")
require.Equal(t, ErrUnauthenticated, err)
_, err = a.Authenticate("phil", "newpass")
require.Nil(t, err)
require.Nil(t, a.ChangePassword("jane", "$2b$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true))
_, err = a.Authenticate("jane", "jane")
require.Equal(t, ErrUnauthenticated, err)
_, err = a.Authenticate("jane", "newpass")
require.Nil(t, err)
}
func TestManager_ChangeRole(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
@ -378,8 +388,8 @@ func TestManager_ChangeRole(t *testing.T) {
func TestManager_Reservations(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll))
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead))
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
@ -460,7 +470,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
AttachmentTotalSizeLimit: 524288000,
AttachmentExpiryDuration: 24 * time.Hour,
}))
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.ChangeTier("ben", "pro"))
require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll))
@ -507,7 +517,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
func TestManager_Token_Valid(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
u, err := a.User("ben")
require.Nil(t, err)
@ -551,7 +561,7 @@ func TestManager_Token_Valid(t *testing.T) {
func TestManager_Token_Invalid(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length
require.Nil(t, u)
@ -570,7 +580,7 @@ func TestManager_Token_NotFound(t *testing.T) {
func TestManager_Token_Expire(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
u, err := a.User("ben")
require.Nil(t, err)
@ -618,7 +628,7 @@ func TestManager_Token_Expire(t *testing.T) {
func TestManager_Token_Extend(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
// Try to extend token for user without token
u, err := a.User("ben")
@ -647,8 +657,8 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
// Tests that tokens are automatically deleted when the maximum number of tokens is reached
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
ben, err := a.User("ben")
require.Nil(t, err)
@ -668,10 +678,10 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
require.NotEmpty(t, token.Value)
philTokens = append(philTokens, token.Value)
// Create 22 tokens for ben (only 20 allowed!)
// Create 62 tokens for ben (only 60 allowed!)
baseTime := time.Now().Add(24 * time.Hour)
benTokens := make([]string, 0)
for i := 0; i < 22; i++ { //
for i := 0; i < 62; i++ { //
token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
require.Nil(t, err)
require.NotEmpty(t, token.Value)
@ -690,7 +700,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
require.Equal(t, ErrUnauthenticated, err)
// Ben: The other tokens should still work
for i := 2; i < 22; i++ {
for i := 2; i < 62; i++ {
userWithToken, err := a.AuthenticateToken(benTokens[i])
require.Nil(t, err, "token[%d]=%s failed", i, benTokens[i])
require.Equal(t, "ben", userWithToken.Name)
@ -710,7 +720,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
require.Nil(t, err)
require.True(t, rows.Next())
require.Nil(t, rows.Scan(&benCount))
require.Equal(t, 20, benCount)
require.Equal(t, 60, benCount)
var philCount int
rows, err = a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, phil.ID)
@ -723,7 +733,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
func TestManager_EnqueueStats_ResetStats(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
// Baseline: No messages or emails
u, err := a.User("ben")
@ -765,7 +775,7 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) {
func TestManager_EnqueueTokenUpdate(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
// Create user and token
u, err := a.User("ben")
@ -798,7 +808,7 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
func TestManager_ChangeSettings(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
// No settings
u, err := a.User("ben")
@ -866,7 +876,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
AttachmentBandwidthLimit: 21474836480,
StripeMonthlyPriceID: "price_2",
}))
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.ChangeTier("phil", "pro"))
ti, err := a.Tier("pro")
@ -981,7 +991,7 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
Name: "Pro",
ReservationLimit: 4,
}))
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.ChangeTier("phil", "pro"))
// Add 10 reservations (pro tier allows that)
@ -1007,7 +1017,7 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
func TestUser_PhoneNumberAddListRemove(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
@ -1032,8 +1042,8 @@ func TestUser_PhoneNumberAddListRemove(t *testing.T) {
func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
ben, err := a.User("ben")

View File

@ -17,10 +17,9 @@ import (
"sync"
"time"
"golang.org/x/time/rate"
"github.com/gabriel-vasile/mimetype"
"golang.org/x/term"
"golang.org/x/time/rate"
)
const (
@ -99,12 +98,26 @@ func SplitKV(s string, sep string) (key string, value string) {
return "", strings.TrimSpace(kv[0])
}
// LastString returns the last string in a slice, or def if s is empty
func LastString(s []string, def string) string {
if len(s) == 0 {
return def
// Map applies a function to each element of a slice and returns a new slice with the results
// Example: Map([]int{1, 2, 3}, func(i int) int { return i * 2 }) -> []int{2, 4, 6}
func Map[T any, U any](slice []T, f func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = f(v)
}
return s[len(s)-1]
return result
}
// Filter returns a new slice containing only the elements of the original slice for which the
// given function returns true.
func Filter[T any](slice []T, f func(T) bool) []T {
result := make([]T, 0)
for _, v := range slice {
if f(v) {
result = append(result, v)
}
}
return result
}
// RandomString returns a random string with a given length

View File

@ -167,11 +167,6 @@ func TestSplitKV(t *testing.T) {
require.Equal(t, "value=with=separator", value)
}
func TestLastString(t *testing.T) {
require.Equal(t, "last", LastString([]string{"first", "second", "last"}, "default"))
require.Equal(t, "default", LastString([]string{}, "default"))
}
func TestQuoteCommand(t *testing.T) {
require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"}))
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))

1433
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -44,8 +44,8 @@
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8",
"vite": "^4.3.9",
"vite-plugin-pwa": "^0.15.0"
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0"
},
"browserslist": {
"production": [