Compare commits

...

54 Commits

Author SHA1 Message Date
binwiederhier
50f3563477 Docs 2025-08-24 21:18:28 -04:00
binwiederhier
e08f3670d1 Fix lint 2025-08-24 13:58:57 -04:00
binwiederhier
4f6f45a9c0 Checks 2025-08-24 13:52:04 -04:00
binwiederhier
3de04b27ab Redirect to login page if require-login is enabled 2025-08-24 13:48:19 -04:00
binwiederhier
ec1f97b726 Merge remote-tracking branch 'theatischbein/feat_optional_require_login' into require-login 2025-08-24 13:34:22 -04:00
binwiederhier
569d89e8f8 Require login 2025-08-24 13:33:16 -04:00
binwiederhier
f2f146e39b Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2025-08-24 13:19:49 -04:00
Philipp C. Heckel
18d08298cc Merge pull request #1432 from binwiederhier/http-clipboard
Fix copy to clipboard on HTTP-only hosted sites
2025-08-24 07:46:20 -04:00
binwiederhier
ebb386af58 Release notes 2025-08-24 07:44:06 -04:00
binwiederhier
b105ed6727 Fix copy to clipboard on HTTP-only hosted sites 2025-08-24 07:42:39 -04:00
Philipp C. Heckel
1916376f8d Merge pull request #1428 from Max-Pare/main
fixed typo in "client.yml" comment
2025-08-24 07:11:32 -04:00
Philipp C. Heckel
965110b2c3 Merge pull request #1430 from hxtmdev/patch-1
Fix base64 snippets in Publishing
2025-08-23 10:44:17 -04:00
Daniel Höxtermann
c8ac104043 Fix base64 snippets in Publishing
-w0 is usually needed for longer outputs
2025-08-23 16:34:50 +02:00
Max-Pare
f6bd0a8d51 fixed typo 2025-08-21 00:05:05 +02:00
Philipp C. Heckel
e39498702d Merge pull request #1425 from DerRockWolf/docs/integrations/heartbeat-monitor
feat(docs): add ntfy-heartbeat-monitor to integrations page
2025-08-17 08:47:26 -04:00
RockWolf
9b97067b10 feat(docs): add ntfy-heartbeat-monitor to integrations page 2025-08-17 13:44:57 +02:00
ssantos
f72f0d800f Translated using Weblate (Portuguese)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2025-08-12 18:02:13 +02:00
binwiederhier
5244e0be14 Fix tests 2025-08-09 10:04:57 -04:00
binwiederhier
6eb25f68ac Update password hash docs, add more validation on password hash 2025-08-09 07:34:19 -04:00
Philipp C. Heckel
efe7c3fa70 Merge pull request #1399 from orblivion/patch-1
Add Ntfy for Sandstorm to integrations.md
2025-08-08 22:21:43 +02:00
Philipp C. Heckel
ce4b2ae9a0 Merge pull request #1421 from binwiederhier/message-cache-lock
Message cache lock
2025-08-08 22:19:24 +02:00
binwiederhier
ba86e08ffe Release notes 2025-08-08 16:19:02 -04:00
binwiederhier
2d9e2356b1 Release notes 2025-08-08 16:13:39 -04:00
binwiederhier
fe5c844a21 Add test 2025-08-08 16:10:49 -04:00
binwiederhier
97410db301 Merge remote-tracking branch 'timofej673/main' into message-cache-lock 2025-08-08 16:06:27 -04:00
binwiederhier
887751cd5d Release notes 2025-08-08 15:34:30 -04:00
Philipp C. Heckel
044326068c Merge pull request #1420 from binwiederhier/debian-stripe
WIP: Add build flags to remove Firebase, Stripe & WebPush (for Debian packaging)
2025-08-08 21:27:22 +02:00
binwiederhier
57a51ab2da Fix tests 2025-08-08 15:16:53 -04:00
binwiederhier
998dbd9054 Undo main.go 2025-08-08 15:02:09 -04:00
binwiederhier
a5a55bd43a Move WebPush tests 2025-08-07 18:54:37 +02:00
binwiederhier
00409d834b Add build flag for webpush 2025-08-07 18:31:42 +02:00
binwiederhier
d9ab7cc78d Add "nowebpush" build tag 2025-08-07 17:39:25 +02:00
binwiederhier
99a2ca8802 Add build tags for Firebase 2025-08-07 17:24:57 +02:00
binwiederhier
ea338ae4fa Make it easy to build without Stripe 2025-08-07 16:41:39 +02:00
binwiederhier
32fa8d43c1 Merge branch 'main' into debian-stripe 2025-08-07 15:34:54 +02:00
Philipp C. Heckel
0f166e0a1d Merge pull request #1417 from orblivion/patch-2
Typo in publish.md
2025-08-05 21:13:39 +02:00
Daniel Krol
46e423fc40 Typo in publish.md 2025-08-05 14:39:57 -04:00
Philipp C. Heckel
eac523dcf9 Merge pull request #1413 from wunter8/change-password-provisioned-user
prevent changing a provisioned user's password
2025-08-05 10:19:45 +02:00
binwiederhier
4225ce2f42 Release notes 2025-08-05 10:12:53 +02:00
binwiederhier
d35dfc14d1 Bump release notes and such 2025-08-05 10:09:58 +02:00
binwiederhier
cef228f880 Derp 2025-08-05 10:01:21 +02:00
binwiederhier
bcfb50b35a Disallow changing provisioned user and tokens 2025-08-05 09:59:23 +02:00
binwiederhier
c4c4916bc8 Do not allow changing tokens, user role, or delete users 2025-08-04 22:22:59 +02:00
Hunter Kehoe
81463614c9 prevent changing a provisioned user's password 2025-08-03 16:07:24 -06:00
binwiederhier
15a7f86344 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2025-07-31 12:59:49 +02:00
Philipp C. Heckel
3c1da90f47 Merge pull request #1384 from binwiederhier/predefined-users
Declarative users
2025-07-31 11:42:32 +02:00
timof
f8082d9481 Update message_cache.go 2025-07-30 00:12:45 +04:00
timof
d9ecee7200 Merge branch 'binwiederhier:main' into main 2025-07-28 10:37:31 +04:00
தமிழ்நேரம்
1b394e9bb8 Translated using Weblate (Tamil)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ta/
2025-07-27 06:03:34 +00:00
Daniel Krol
4eb7dc563c Add Ntfy for Sandstorm to integrations.md 2025-07-22 18:50:18 -04:00
timof
214f70e62f Merge branch 'binwiederhier:main' into main 2025-07-21 16:52:25 +04:00
timof
006f73af7d Update message_cache.go
Added lock in add_messages to avoid "database is locked" error
Small code reformatting
2025-07-21 12:02:06 +04:00
binwiederhier
5ccc131e73 Derp 2025-07-04 06:41:14 +02:00
Thea Tischbein
03aeb707f2 feat: Add optional web app flag which requires a login for every action 2025-05-05 11:39:53 +02:00
47 changed files with 738 additions and 239 deletions

View File

@@ -21,7 +21,7 @@
# default-command: # default-command:
# Subscriptions to topics and their actions. This option is primarily used by the systemd service, # Subscriptions to topics and their actions. This option is primarily used by the systemd service,
# or if you cann "ntfy subscribe --from-config" directly. # or if you can "ntfy subscribe --from-config" directly.
# #
# Example: # Example:
# subscribe: # subscribe:

View File

@@ -16,10 +16,10 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/stripe/stripe-go/v74"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
@@ -63,6 +63,7 @@ var flagsServe = append(
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "require-login", Aliases: []string{"require_login"}, EnvVars: []string{"NTFY_REQUIRE_LOGIN"}, Value: false, Usage: "all actions via the web app requires a login"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
@@ -171,6 +172,7 @@ func execServe(c *cli.Context) error {
webRoot := c.String("web-root") webRoot := c.String("web-root")
enableSignup := c.Bool("enable-signup") enableSignup := c.Bool("enable-signup")
enableLogin := c.Bool("enable-login") enableLogin := c.Bool("enable-login")
requireLogin := c.Bool("require-login")
enableReservations := c.Bool("enable-reservations") enableReservations := c.Bool("enable-reservations")
upstreamBaseURL := c.String("upstream-base-url") upstreamBaseURL := c.String("upstream-base-url")
upstreamAccessToken := c.String("upstream-access-token") upstreamAccessToken := c.String("upstream-access-token")
@@ -279,6 +281,8 @@ func execServe(c *cli.Context) error {
// Check values // Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist") return errors.New("if set, FCM key file must exist")
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)")
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") { } else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys") return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
} else if keepaliveInterval < 5*time.Second { } else if keepaliveInterval < 5*time.Second {
@@ -316,10 +320,14 @@ func execServe(c *cli.Context) error {
return errors.New("if upstream-base-url is set, base-url must also be set") return errors.New("if upstream-base-url is set, base-url must also be set")
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL { } else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications") return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") { } else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set") return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
} else if enableSignup && !enableLogin { } else if enableSignup && !enableLogin {
return errors.New("cannot set enable-signup without also setting enable-login") return errors.New("cannot set enable-signup without also setting enable-login")
} else if requireLogin && !enableLogin {
return errors.New("cannot set require-login without also setting enable-login")
} else if !payments.Available && (stripeSecretKey != "" || stripeWebhookKey != "") {
return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)")
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
@@ -329,6 +337,8 @@ func execServe(c *cli.Context) error {
if messageSizeLimit > 5*1024*1024 { if messageSizeLimit > 5*1024*1024 {
return errors.New("message-size-limit cannot be higher than 5M") return errors.New("message-size-limit cannot be higher than 5M")
} }
} else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration { } else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration") return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
} else if behindProxy && proxyForwardedHeader == "" { } else if behindProxy && proxyForwardedHeader == "" {
@@ -396,8 +406,7 @@ func execServe(c *cli.Context) error {
// Stripe things // Stripe things
if stripeSecretKey != "" { if stripeSecretKey != "" {
stripe.EnableTelemetry = false // Whoa! payments.Setup(stripeSecretKey)
stripe.Key = stripeSecretKey
} }
// Add default forbidden topics // Add default forbidden topics
@@ -470,6 +479,7 @@ func execServe(c *cli.Context) error {
conf.BillingContact = billingContact conf.BillingContact = billingContact
conf.EnableSignup = enableSignup conf.EnableSignup = enableSignup
conf.EnableLogin = enableLogin conf.EnableLogin = enableLogin
conf.RequireLogin = requireLogin
conf.EnableReservations = enableReservations conf.EnableReservations = enableReservations
conf.EnableMetrics = enableMetrics conf.EnableMetrics = enableMetrics
conf.MetricsListenHTTP = metricsListenHTTP conf.MetricsListenHTTP = metricsListenHTTP
@@ -550,8 +560,8 @@ func parseUsers(usersRaw []string) ([]*user.User, error) {
role := user.Role(strings.TrimSpace(parts[2])) role := user.Role(strings.TrimSpace(parts[2]))
if !user.AllowedUsername(username) { if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine) return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine)
} else if err := user.ValidPasswordHash(passwordHash); err != nil { } else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error()) return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
} else if !user.AllowedRole(role) { } else if !user.AllowedRole(role) {
return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
} }

View File

@@ -26,11 +26,11 @@ func TestParseUsers_Success(t *testing.T) {
}{ }{
{ {
name: "single user", name: "single user",
input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
expected: []*user.User{ expected: []*user.User{
{ {
Name: "alice", Name: "alice",
Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
Role: user.RoleUser, Role: user.RoleUser,
Provisioned: true, Provisioned: true,
}, },
@@ -39,19 +39,19 @@ func TestParseUsers_Success(t *testing.T) {
{ {
name: "multiple users with different roles", name: "multiple users with different roles",
input: []string{ input: []string{
"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user", "alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
"bob:$2b$10$abcdefghijklmnopqrstuvwxyz:admin", "bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
}, },
expected: []*user.User{ expected: []*user.User{
{ {
Name: "alice", Name: "alice",
Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
Role: user.RoleUser, Role: user.RoleUser,
Provisioned: true, Provisioned: true,
}, },
{ {
Name: "bob", Name: "bob",
Hash: "$2b$10$abcdefghijklmnopqrstuvwxyz", Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
Role: user.RoleAdmin, Role: user.RoleAdmin,
Provisioned: true, Provisioned: true,
}, },
@@ -64,11 +64,11 @@ func TestParseUsers_Success(t *testing.T) {
}, },
{ {
name: "user with special characters in name", name: "user with special characters in name",
input: []string{"alice.test+123@example.com:$2y$10$abcdefghijklmnopqrstuvwxyz:user"}, input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"},
expected: []*user.User{ expected: []*user.User{
{ {
Name: "alice.test+123@example.com", Name: "alice.test+123@example.com",
Hash: "$2y$10$abcdefghijklmnopqrstuvwxyz", Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
Role: user.RoleUser, Role: user.RoleUser,
Provisioned: true, Provisioned: true,
}, },
@@ -110,23 +110,23 @@ func TestParseUsers_Errors(t *testing.T) {
}, },
{ {
name: "invalid username", name: "invalid username",
input: []string{"alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
error: "invalid auth-users: alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
}, },
{ {
name: "invalid password hash - wrong prefix", name: "invalid password hash - wrong prefix",
input: []string{"alice:plaintext:user"}, input: []string{"alice:plaintext:user"},
error: "invalid auth-users: alice:plaintext:user, password hash but be a bcrypt hash, use 'ntfy user hash' to generate", error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate",
}, },
{ {
name: "invalid role", name: "invalid role",
input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid"}, input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
error: "invalid auth-users: alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'", error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
}, },
{ {
name: "empty username", name: "empty username",
input: []string{":$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
error: "invalid auth-users: :$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
}, },
} }

View File

@@ -60,6 +60,9 @@ func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
func TestCLI_User_ChangePass(t *testing.T) { func TestCLI_User_ChangePass(t *testing.T) {
s, conf, port := newTestServerWithAuth(t) s, conf, port := newTestServerWithAuth(t)
conf.AuthUsers = []*user.User{
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
}
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
// Add user // Add user
@@ -73,6 +76,11 @@ func TestCLI_User_ChangePass(t *testing.T) {
stdin.WriteString("newpass\nnewpass") stdin.WriteString("newpass\nnewpass")
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil")) require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
require.Contains(t, stdout.String(), "changed password for user phil") require.Contains(t, stdout.String(), "changed password for user phil")
// Cannot change provisioned user's pass
app, stdin, _, _ = newTestApp()
stdin.WriteString("newpass\nnewpass")
require.Error(t, runUserCommand(app, conf, "change-pass", "philuser"))
} }
func TestCLI_User_ChangeRole(t *testing.T) { func TestCLI_User_ChangeRole(t *testing.T) {

View File

@@ -1,4 +1,4 @@
//go:build !noserver //go:build !noserver && !nowebpush
package cmd package cmd

View File

@@ -88,7 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`):
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
NTFY_AUTH_DEFAULT_ACCESS: deny-all NTFY_AUTH_DEFAULT_ACCESS: deny-all
NTFY_AUTH_USERS: 'phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
NTFY_BEHIND_PROXY: true NTFY_BEHIND_PROXY: true
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
NTFY_ENABLE_LOGIN: true NTFY_ENABLE_LOGIN: true
@@ -1698,6 +1698,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | | `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | | `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | | `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login |
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | | `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | | `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | | `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |

View File

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

View File

@@ -176,6 +176,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell) - [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) - [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. - [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
## Blog + forum posts ## Blog + forum posts

View File

@@ -1106,7 +1106,7 @@ Which will result in a notification that looks like this:
When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your
webhook payload. webhook payload.
Inline templates are most useful for templated one-off messages, of if you do not control the ntfy server (e.g., if you're using ntfy.sh). Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh).
Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead, Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead,
if you control the ntfy server, as templates are much easier to maintain. if you control the ntfy server, as templates are much easier to maintain.
@@ -3679,13 +3679,13 @@ authParam = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM0
The following command will generate the appropriate value for you on *nix systems: The following command will generate the appropriate value for you on *nix systems:
``` ```
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '=' echo -n "Basic `echo -n 'testuser:fakepassword' | base64 -w0`" | base64 -w0 | tr -d '='
``` ```
For access tokens, you can use this instead: For access tokens, you can use this instead:
``` ```
echo -n "Bearer faketoken" | base64 | tr -d '=' echo -n "Bearer faketoken" | base64 -w0 | tr -d '='
``` ```
## Advanced features ## Advanced features

View File

@@ -2,6 +2,22 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) 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). and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
### ntfy server v2.14.0
Released August 5, 2025
This release adds support for [declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config). This allows you to define users, ACL entries and tokens in the config file, which is useful for static deployments or deployments that use a configuration management system.
It also adds support for [pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support, as well as advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) functions.
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy
will always remain open source.
**Features:**
* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), [#1413](https://github.com/binwiederhier/ntfy/pull/1413), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing and implementing parts of it)
* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))
* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work)
### ntfy server v2.13.0 ### ntfy server v2.13.0
Released July 10, 2025 Released July 10, 2025
@@ -1452,13 +1468,18 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy server v2.14.0 (UNRELEASED) ### ntfy server v2.15.0 (UNRELEASED)
**Features:** **Features:**
* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing) * Add `require-login` flag to redirect to login page if not logged in ([#1434](https://github.com/binwiederhier/ntfy/pull/1434)/[#238](https://github.com/binwiederhier/ntfy/issues/238)/[#1329](https://github.com/binwiederhier/ntfy/pull/1329), thanks to [@theatischbein](https://github.com/theatischbein) for implementing most of this)
* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))
* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work) **Bug fixes + maintenance:**
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
### ntfy Android app v1.16.1 (UNRELEASED) ### ntfy Android app v1.16.1 (UNRELEASED)

12
go.mod
View File

@@ -30,10 +30,10 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
require github.com/pkg/errors v0.9.1 // indirect require github.com/pkg/errors v0.9.1 // indirect
require ( require (
firebase.google.com/go/v4 v4.17.0 firebase.google.com/go/v4 v4.18.0
github.com/SherClockHolmes/webpush-go v1.4.0 github.com/SherClockHolmes/webpush-go v1.4.0
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_golang v1.23.0
github.com/stripe/stripe-go/v74 v74.30.0 github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/text v0.27.0 golang.org/x/text v0.27.0
) )
@@ -61,7 +61,7 @@ require (
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // 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/v4 v4.5.2 // indirect
@@ -95,9 +95,9 @@ require (
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.34.0 // indirect golang.org/x/sys v0.34.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250728155136-f173205681a0 // indirect google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.74.2 // indirect google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

24
go.sum
View File

@@ -22,8 +22,8 @@ cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsL
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
firebase.google.com/go/v4 v4.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE= firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= 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/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 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
@@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -127,8 +127,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
@@ -265,12 +265,12 @@ google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8= google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= 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/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250728155136-f173205681a0 h1:btBcgujH2+KIWEfz0s7Cdtt9R7hpwM4SAEXAdXf/ddw= google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b h1:eZTgydvqZO44zyTZAvMaSyAxccZZdraiSAGvqOczVvk=
google.golang.org/genproto v0.0.0-20250728155136-f173205681a0/go.mod h1:Q4yZQ3kmmIyg6HsMjCGx2vQ8gzN+dntaPmFWz6Zj0fo= google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:suyz2QBHQKlGIF92HEEsCfO1SwxXdk7PFLz+Zd9Uah4=
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8= google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

21
payments/payments.go Normal file
View File

@@ -0,0 +1,21 @@
//go:build !nopayments
package payments
import "github.com/stripe/stripe-go/v74"
// Available is a constant used to indicate that Stripe support is available.
// It can be disabled with the 'nopayments' build tag.
const Available = true
// SubscriptionStatus is an alias for stripe.SubscriptionStatus
type SubscriptionStatus stripe.SubscriptionStatus
// PriceRecurringInterval is an alias for stripe.PriceRecurringInterval
type PriceRecurringInterval stripe.PriceRecurringInterval
// Setup sets the Stripe secret key and disables telemetry
func Setup(stripeSecretKey string) {
stripe.EnableTelemetry = false // Whoa!
stripe.Key = stripeSecretKey
}

View File

@@ -0,0 +1,18 @@
//go:build nopayments
package payments
// Available is a constant used to indicate that Stripe support is available.
// It can be disabled with the 'nopayments' build tag.
const Available = false
// SubscriptionStatus is a dummy type
type SubscriptionStatus string
// PriceRecurringInterval is dummy type
type PriceRecurringInterval string
// Setup is a dummy type
func Setup(stripeSecretKey string) {
// Nothing to see here
}

View File

@@ -162,6 +162,7 @@ type Config struct {
BillingContact string BillingContact string
EnableSignup bool // Enable creation of accounts via API and UI EnableSignup bool // Enable creation of accounts via API and UI
EnableLogin bool EnableLogin bool
RequireLogin bool
EnableReservations bool // Allow users with role "user" to own/reserve topics EnableReservations bool // Allow users with role "user" to own/reserve topics
EnableMetrics bool EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients AccessControlAllowOrigin string // CORS header field to restrict access from web clients
@@ -256,6 +257,7 @@ func NewConfig() *Config {
EnableSignup: false, EnableSignup: false,
EnableLogin: false, EnableLogin: false,
EnableReservations: false, EnableReservations: false,
RequireLogin: false,
AccessControlAllowOrigin: "*", AccessControlAllowOrigin: "*",
Version: "", Version: "",
WebPushPrivateKey: "", WebPushPrivateKey: "",

View File

@@ -132,6 +132,8 @@ var (
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil} errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil} errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil} errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}

View File

@@ -8,6 +8,7 @@ import (
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
@@ -283,6 +284,7 @@ type messageCache struct {
db *sql.DB db *sql.DB
queue *util.BatchingQueue[*message] queue *util.BatchingQueue[*message]
nop bool nop bool
mu sync.Mutex
} }
// newSqliteCache creates a SQLite file-backed cache // newSqliteCache creates a SQLite file-backed cache
@@ -347,6 +349,8 @@ func (c *messageCache) AddMessage(m *message) error {
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until // addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
// SQLite's busy_timeout is exceeded before erroring out. // SQLite's busy_timeout is exceeded before erroring out.
func (c *messageCache) addMessages(ms []*message) error { func (c *messageCache) addMessages(ms []*message) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.nop { if c.nop {
return nil return nil
} }
@@ -528,6 +532,8 @@ func (c *messageCache) Message(id string) (*message, error) {
} }
func (c *messageCache) MarkPublished(m *message) error { func (c *messageCache) MarkPublished(m *message) error {
c.mu.Lock()
defer c.mu.Unlock()
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID) _, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
return err return err
} }
@@ -573,6 +579,8 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
} }
func (c *messageCache) DeleteMessages(ids ...string) error { func (c *messageCache) DeleteMessages(ids ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
tx, err := c.db.Begin() tx, err := c.db.Begin()
if err != nil { if err != nil {
return err return err
@@ -587,6 +595,8 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
} }
func (c *messageCache) ExpireMessages(topics ...string) error { func (c *messageCache) ExpireMessages(topics ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
tx, err := c.db.Begin() tx, err := c.db.Begin()
if err != nil { if err != nil {
return err return err
@@ -621,6 +631,8 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
} }
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error { func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
tx, err := c.db.Begin() tx, err := c.db.Begin()
if err != nil { if err != nil {
return err return err
@@ -766,6 +778,8 @@ func readMessage(rows *sql.Rows) (*message, error) {
} }
func (c *messageCache) UpdateStats(messages int64) error { func (c *messageCache) UpdateStats(messages int64) error {
c.mu.Lock()
defer c.mu.Unlock()
_, err := c.db.Exec(updateStatsQuery, messages) _, err := c.db.Exec(updateStatsQuery, messages)
return err return err
} }

View File

@@ -3,8 +3,10 @@ package server
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"github.com/stretchr/testify/assert"
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
"time" "time"
@@ -90,6 +92,26 @@ func testCacheMessages(t *testing.T, c *messageCache) {
require.Empty(t, messages) require.Empty(t, messages)
} }
func TestSqliteCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newSqliteTestCache(t))
}
func TestMemCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newMemTestCache(t))
}
func testCacheMessagesLock(t *testing.T, c *messageCache) {
var wg sync.WaitGroup
for i := 0; i < 5000; i++ {
wg.Add(1)
go func() {
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
wg.Done()
}()
}
wg.Wait()
}
func TestSqliteCache_MessagesScheduled(t *testing.T) { func TestSqliteCache_MessagesScheduled(t *testing.T) {
testCacheMessagesScheduled(t, newSqliteTestCache(t)) testCacheMessagesScheduled(t, newSqliteTestCache(t))
} }

View File

@@ -9,7 +9,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"gopkg.in/yaml.v2"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -32,7 +31,9 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig" "heckel.io/ntfy/v2/util/sprig"
@@ -165,7 +166,7 @@ func New(conf *Config) (*Server, error) {
mailer = &smtpSender{config: conf} mailer = &smtpSender{config: conf}
} }
var stripe stripeAPI var stripe stripeAPI
if conf.StripeSecretKey != "" { if payments.Available && conf.StripeSecretKey != "" {
stripe = newStripeAPI() stripe = newStripeAPI()
} }
messageCache, err := createMessageCache(conf) messageCache, err := createMessageCache(conf)
@@ -599,6 +600,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
BaseURL: "", // Will translate to window.location.origin BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot, AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin, EnableLogin: s.config.EnableLogin,
RequireLogin: s.config.RequireLogin,
EnableSignup: s.config.EnableSignup, EnableSignup: s.config.EnableSignup,
EnablePayments: s.config.StripeSecretKey != "", EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "", EnableCalls: s.config.TwilioAccount != "",

View File

@@ -258,9 +258,11 @@
# #
# - enable-signup allows users to sign up via the web app, or API # - enable-signup allows users to sign up via the web app, or API
# - enable-login allows users to log in via the web app, or API # - enable-login allows users to log in via the web app, or API
# - require-login redirects users to the login page if they are not logged in (disallows web app access without login)
# - enable-reservations allows users to reserve topics (if their tier allows it) # - enable-reservations allows users to reserve topics (if their tier allows it)
# #
# enable-signup: false # enable-signup: false
# require-login: false
# enable-login: false # enable-login: false
# enable-reservations: false # enable-reservations: false

View File

@@ -85,6 +85,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
response.Username = u.Name response.Username = u.Name
response.Role = string(u.Role) response.Role = string(u.Role)
response.SyncTopic = u.SyncTopic response.SyncTopic = u.SyncTopic
response.Provisioned = u.Provisioned
if u.Prefs != nil { if u.Prefs != nil {
if u.Prefs.Language != nil { if u.Prefs.Language != nil {
response.Language = *u.Prefs.Language response.Language = *u.Prefs.Language
@@ -144,6 +145,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
LastAccess: t.LastAccess.Unix(), LastAccess: t.LastAccess.Unix(),
LastOrigin: lastOrigin, LastOrigin: lastOrigin,
Expires: t.Expires.Unix(), Expires: t.Expires.Unix(),
Provisioned: t.Provisioned,
}) })
} }
} }
@@ -174,6 +176,12 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil { if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
return errHTTPBadRequestIncorrectPasswordConfirmation return errHTTPBadRequestIncorrectPasswordConfirmation
} }
if err := s.userManager.CanChangeUser(u.Name); err != nil {
if errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPConflictProvisionedUserChange
}
return err
}
if s.webPush != nil && u.ID != "" { if s.webPush != nil && u.ID != "" {
if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil { if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name) logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
@@ -208,6 +216,9 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
} }
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name) logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil { if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
if errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPConflictProvisionedUserChange
}
return err return err
} }
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
@@ -274,6 +285,9 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request
Debug("Updating token for user %s as deleted", u.Name) Debug("Updating token for user %s as deleted", u.Name)
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires) token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
if err != nil { if err != nil {
if errors.Is(err, user.ErrProvisionedTokenChange) {
return errHTTPConflictProvisionedTokenChange
}
return err return err
} }
response := &apiAccountTokenResponse{ response := &apiAccountTokenResponse{
@@ -296,6 +310,9 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request
} }
} }
if err := s.userManager.RemoveToken(u.ID, token); err != nil { if err := s.userManager.RemoveToken(u.ID, token); err != nil {
if errors.Is(err, user.ErrProvisionedTokenChange) {
return errHTTPConflictProvisionedTokenChange
}
return err return err
} }
logvr(v, r). logvr(v, r).

View File

@@ -251,7 +251,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
} }
func TestAccount_ChangePassword(t *testing.T) { func TestAccount_ChangePassword(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t)) conf := newTestConfigWithAuthFile(t)
conf.AuthUsers = []*user.User{
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
}
s := newTestServer(t, conf)
defer s.closeDatabases() defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -281,6 +285,12 @@ func TestAccount_ChangePassword(t *testing.T) {
"Authorization": util.BasicAuth("phil", "new password"), "Authorization": util.BasicAuth("phil", "new password"),
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
// Cannot change password of provisioned user
rr = request(t, s, "POST", "/v1/account/password", `{"password": "philpass", "new_password": "new password"}`, map[string]string{
"Authorization": util.BasicAuth("philuser", "philpass"),
})
require.Equal(t, 409, rr.Code)
} }
func TestAccount_ChangePassword_NoAccount(t *testing.T) { func TestAccount_ChangePassword_NoAccount(t *testing.T) {

View File

@@ -1,3 +1,5 @@
//go:build !nofirebase
package server package server
import ( import (
@@ -14,6 +16,10 @@ import (
) )
const ( const (
// FirebaseAvailable is a constant used to indicate that Firebase support is available.
// It can be disabled with the 'nofirebase' build tag.
FirebaseAvailable = true
fcmMessageLimit = 4000 fcmMessageLimit = 4000
fcmApnsBodyMessageLimit = 100 fcmApnsBodyMessageLimit = 100
) )
@@ -73,7 +79,7 @@ type firebaseSenderImpl struct {
client *messaging.Client client *messaging.Client
} }
func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) { func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile)) fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -0,0 +1,38 @@
//go:build nofirebase
package server
import (
"errors"
"heckel.io/ntfy/v2/user"
)
const (
// FirebaseAvailable is a constant used to indicate that Firebase support is available.
// It can be disabled with the 'nofirebase' build tag.
FirebaseAvailable = false
)
var (
errFirebaseNotAvailable = errors.New("Firebase not available")
errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase")
)
type firebaseClient struct {
}
func (c *firebaseClient) Send(v *visitor, m *message) error {
return errFirebaseNotAvailable
}
type firebaseSender interface {
Send(m string) error
}
func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClient {
return nil
}
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
return nil, errFirebaseNotAvailable
}

View File

@@ -1,3 +1,5 @@
//go:build !nofirebase
package server package server
import ( import (

View File

@@ -1,3 +1,5 @@
//go:build !nopayments
package server package server
import ( import (
@@ -12,6 +14,7 @@ import (
"github.com/stripe/stripe-go/v74/subscription" "github.com/stripe/stripe-go/v74/subscription"
"github.com/stripe/stripe-go/v74/webhook" "github.com/stripe/stripe-go/v74/webhook"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"io" "io"
@@ -22,7 +25,7 @@ import (
// Payments in ntfy are done via Stripe. // Payments in ntfy are done via Stripe.
// //
// Pretty much all payments related things are in this file. The following processes // Pretty much all payments-related things are in this file. The following processes
// handle payments: // handle payments:
// //
// - Checkout: // - Checkout:
@@ -464,8 +467,8 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
billing := &user.Billing{ billing := &user.Billing{
StripeCustomerID: customerID, StripeCustomerID: customerID,
StripeSubscriptionID: subscriptionID, StripeSubscriptionID: subscriptionID,
StripeSubscriptionStatus: stripe.SubscriptionStatus(status), StripeSubscriptionStatus: payments.SubscriptionStatus(status),
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval), StripeSubscriptionInterval: payments.PriceRecurringInterval(interval),
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0), StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0), StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
} }

View File

@@ -0,0 +1,47 @@
//go:build nopayments
package server
import (
"net/http"
)
type stripeAPI interface {
CancelSubscription(id string) (string, error)
}
func newStripeAPI() stripeAPI {
return nil
}
func (s *Server) fetchStripePrices() (map[string]int64, error) {
return nil, errHTTPNotFound
}
func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
return errHTTPNotFound
}
func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
return errHTTPNotFound
}
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {
return errHTTPNotFound
}
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
return errHTTPNotFound
}
func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
return errHTTPNotFound
}
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
return errHTTPNotFound
}
func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {
return errHTTPNotFound
}

View File

@@ -1,3 +1,5 @@
//go:build !nopayments
package server package server
import ( import (
@@ -6,6 +8,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"io" "io"
@@ -345,8 +348,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
require.Nil(t, u.Tier) require.Nil(t, u.Tier)
require.Equal(t, "", u.Billing.StripeCustomerID) require.Equal(t, "", u.Billing.StripeCustomerID)
require.Equal(t, "", u.Billing.StripeSubscriptionID) require.Equal(t, "", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval) require.Equal(t, payments.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users! require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
@@ -362,8 +365,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
require.Equal(t, "starter", u.Tier.Code) // Not "pro" require.Equal(t, "starter", u.Tier.Code) // Not "pro"
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus)
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval) require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval)
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix()) require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
require.Equal(t, int64(0), u.Stats.Messages) require.Equal(t, int64(0), u.Stats.Messages)
@@ -473,8 +476,8 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
billing := &user.Billing{ billing := &user.Billing{
StripeCustomerID: "acct_5555", StripeCustomerID: "acct_5555",
StripeSubscriptionID: "sub_1234", StripeSubscriptionID: "sub_1234",
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue, StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
StripeSubscriptionPaidUntil: time.Unix(123, 0), StripeSubscriptionPaidUntil: time.Unix(123, 0),
StripeSubscriptionCancelAt: time.Unix(456, 0), StripeSubscriptionCancelAt: time.Unix(456, 0),
} }
@@ -517,8 +520,8 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Equal(t, "starter", u.Tier.Code) // Not "pro" require.Equal(t, "starter", u.Tier.Code) // Not "pro"
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due" require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) // Not "past_due"
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month" require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalYear), u.Billing.StripeSubscriptionInterval) // Not "month"
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
@@ -580,8 +583,8 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{ require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
StripeCustomerID: "acct_5555", StripeCustomerID: "acct_5555",
StripeSubscriptionID: "sub_1234", StripeSubscriptionID: "sub_1234",
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue, StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
StripeSubscriptionPaidUntil: time.Unix(123, 0), StripeSubscriptionPaidUntil: time.Unix(123, 0),
StripeSubscriptionCancelAt: time.Unix(0, 0), StripeSubscriptionCancelAt: time.Unix(0, 0),
})) }))
@@ -598,7 +601,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
require.Nil(t, u.Tier) require.Nil(t, u.Tier)
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "", u.Billing.StripeSubscriptionID) require.Equal(t, "", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())

View File

@@ -23,7 +23,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
@@ -281,30 +280,6 @@ func TestServer_WebEnabled(t *testing.T) {
rr = request(t, s2, "GET", "/app.html", "", nil) rr = request(t, s2, "GET", "/app.html", "", nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
} }
func TestServer_WebPushEnabled(t *testing.T) {
conf := newTestConfig(t)
conf.WebRoot = "" // Disable web app
s := newTestServer(t, conf)
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
conf2 := newTestConfig(t)
s2 := newTestServer(t, conf2)
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
conf3 := newTestConfigWithWebPush(t)
s3 := newTestServer(t, conf3)
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 200, rr.Code)
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
}
func TestServer_PublishLargeMessage(t *testing.T) { func TestServer_PublishLargeMessage(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.AttachmentCacheDir = "" // Disable attachments c.AttachmentCacheDir = "" // Disable attachments
@@ -3257,17 +3232,6 @@ func newTestConfigWithAuthFile(t *testing.T) *Config {
return conf return conf
} }
func newTestConfigWithWebPush(t *testing.T) *Config {
conf := newTestConfig(t)
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
require.Nil(t, err)
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
conf.WebPushEmailAddress = "testing@example.com"
conf.WebPushPrivateKey = privateKey
conf.WebPushPublicKey = publicKey
return conf
}
func newTestServer(t *testing.T, config *Config) *Server { func newTestServer(t *testing.T, config *Config) *Server {
server, err := New(config) server, err := New(config)
require.Nil(t, err) require.Nil(t, err)

View File

@@ -1,3 +1,5 @@
//go:build !nowebpush
package server package server
import ( import (
@@ -13,6 +15,10 @@ import (
) )
const ( const (
// WebPushAvailable is a constant used to indicate that WebPush support is available.
// It can be disabled with the 'nowebpush' build tag.
WebPushAvailable = true
webPushTopicSubscribeLimit = 50 webPushTopicSubscribeLimit = 50
) )

View File

@@ -0,0 +1,29 @@
//go:build nowebpush
package server
import (
"net/http"
)
const (
// WebPushAvailable is a constant used to indicate that WebPush support is available.
// It can be disabled with the 'nowebpush' build tag.
WebPushAvailable = false
)
func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
return errHTTPNotFound
}
func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {
return errHTTPNotFound
}
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
// Nothing to see here
}
func (s *Server) pruneAndNotifyWebPushSubscriptions() {
// Nothing to see here
}

View File

@@ -1,8 +1,11 @@
//go:build !nowebpush
package server package server
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
@@ -10,6 +13,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"path/filepath"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -20,6 +24,28 @@ const (
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
) )
func TestServer_WebPush_Enabled(t *testing.T) {
conf := newTestConfig(t)
conf.WebRoot = "" // Disable web app
s := newTestServer(t, conf)
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
conf2 := newTestConfig(t)
s2 := newTestServer(t, conf2)
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
conf3 := newTestConfigWithWebPush(t)
s3 := newTestServer(t, conf3)
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 200, rr.Code)
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
}
func TestServer_WebPush_Disabled(t *testing.T) { func TestServer_WebPush_Disabled(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
@@ -254,3 +280,14 @@ func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLen
require.Nil(t, err) require.Nil(t, err)
require.Len(t, subs, expectedLength) require.Len(t, subs, expectedLength)
} }
func newTestConfigWithWebPush(t *testing.T) *Config {
conf := newTestConfig(t)
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
require.Nil(t, err)
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
conf.WebPushEmailAddress = "testing@example.com"
conf.WebPushPrivateKey = privateKey
conf.WebPushPublicKey = publicKey
return conf
}

View File

@@ -365,6 +365,7 @@ type apiAccountTokenResponse struct {
LastAccess int64 `json:"last_access,omitempty"` LastAccess int64 `json:"last_access,omitempty"`
LastOrigin string `json:"last_origin,omitempty"` LastOrigin string `json:"last_origin,omitempty"`
Expires int64 `json:"expires,omitempty"` // Unix timestamp Expires int64 `json:"expires,omitempty"` // Unix timestamp
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
} }
type apiAccountPhoneNumberVerifyRequest struct { type apiAccountPhoneNumberVerifyRequest struct {
@@ -426,6 +427,7 @@ type apiAccountResponse struct {
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role,omitempty"` Role string `json:"role,omitempty"`
SyncTopic string `json:"sync_topic,omitempty"` SyncTopic string `json:"sync_topic,omitempty"`
Provisioned bool `json:"provisioned,omitempty"`
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
Notification *user.NotificationPrefs `json:"notification,omitempty"` Notification *user.NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
@@ -447,6 +449,7 @@ type apiConfigResponse struct {
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
AppRoot string `json:"app_root"` AppRoot string `json:"app_root"`
EnableLogin bool `json:"enable_login"` EnableLogin bool `json:"enable_login"`
RequireLogin bool `json:"require_login"`
EnableSignup bool `json:"enable_signup"` EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"` EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"` EnableCalls bool `json:"enable_calls"`

View File

@@ -7,9 +7,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/mattn/go-sqlite3" "github.com/mattn/go-sqlite3"
"github.com/stripe/stripe-go/v74"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"net/netip" "net/netip"
"path/filepath" "path/filepath"
@@ -773,6 +773,9 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time
if token == "" { if token == "" {
return nil, errNoTokenProvided return nil, errNoTokenProvided
} }
if err := a.CanChangeToken(userID, token); err != nil {
return nil, err
}
tx, err := a.db.Begin() tx, err := a.db.Begin()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -796,6 +799,9 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time
// RemoveToken deletes the token defined in User.Token // RemoveToken deletes the token defined in User.Token
func (a *Manager) RemoveToken(userID, token string) error { func (a *Manager) RemoveToken(userID, token string) error {
if err := a.CanChangeToken(userID, token); err != nil {
return err
}
return execTx(a.db, func(tx *sql.Tx) error { return execTx(a.db, func(tx *sql.Tx) error {
return a.removeTokenTx(tx, userID, token) return a.removeTokenTx(tx, userID, token)
}) })
@@ -811,6 +817,17 @@ func (a *Manager) removeTokenTx(tx *sql.Tx, userID, token string) error {
return nil return nil
} }
// CanChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed.
func (a *Manager) CanChangeToken(userID, token string) error {
t, err := a.Token(userID, token)
if err != nil {
return err
} else if t.Provisioned {
return ErrProvisionedTokenChange
}
return nil
}
// RemoveExpiredTokens deletes all expired tokens from the database // RemoveExpiredTokens deletes all expired tokens from the database
func (a *Manager) RemoveExpiredTokens() error { func (a *Manager) RemoveExpiredTokens() error {
if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil { if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil {
@@ -1049,7 +1066,7 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha
var err error = nil var err error = nil
if hashed { if hashed {
hash = password hash = password
if err := ValidPasswordHash(hash); err != nil { if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err return err
} }
} else { } else {
@@ -1072,6 +1089,9 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha
// RemoveUser deletes the user with the given username. The function returns nil on success, even // RemoveUser deletes the user with the given username. The function returns nil on success, even
// if the user did not exist in the first place. // if the user did not exist in the first place.
func (a *Manager) RemoveUser(username string) error { func (a *Manager) RemoveUser(username string) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return execTx(a.db, func(tx *sql.Tx) error { return execTx(a.db, func(tx *sql.Tx) error {
return a.removeUserTx(tx, username) return a.removeUserTx(tx, username)
}) })
@@ -1224,8 +1244,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
Billing: &Billing{ Billing: &Billing{
StripeCustomerID: stripeCustomerID.String, // May be empty StripeCustomerID: stripeCustomerID.String, // May be empty
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
StripeSubscriptionInterval: stripe.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
}, },
@@ -1389,17 +1409,32 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
// ChangePassword changes a user's password // ChangePassword changes a user's password
func (a *Manager) ChangePassword(username, password string, hashed bool) error { func (a *Manager) ChangePassword(username, password string, hashed bool) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return execTx(a.db, func(tx *sql.Tx) error { return execTx(a.db, func(tx *sql.Tx) error {
return a.changePasswordTx(tx, username, password, hashed) return a.changePasswordTx(tx, username, password, hashed)
}) })
} }
// CanChangeUser checks if the user with the given username can be changed.
// This is used to prevent changes to provisioned users, which are defined in the config file.
func (a *Manager) CanChangeUser(username string) error {
user, err := a.User(username)
if err != nil {
return err
} else if user.Provisioned {
return ErrProvisionedUserChange
}
return nil
}
func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error { func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
var hash string var hash string
var err error var err error
if hashed { if hashed {
hash = password hash = password
if err := ValidPasswordHash(hash); err != nil { if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err return err
} }
} else { } else {
@@ -1417,6 +1452,9 @@ func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
// all existing access control entries (Grant) are removed, since they are no longer needed. // all existing access control entries (Grant) are removed, since they are no longer needed.
func (a *Manager) ChangeRole(username string, role Role) error { func (a *Manager) ChangeRole(username string, role Role) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return execTx(a.db, func(tx *sql.Tx) error { return execTx(a.db, func(tx *sql.Tx) error {
return a.changeRoleTx(tx, username, role) return a.changeRoleTx(tx, username, role)
}) })
@@ -1437,14 +1475,8 @@ func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error {
return nil return nil
} }
// ChangeProvisioned changes the provisioned status of a user. This is used to mark users as // changeProvisionedTx changes the provisioned status of a user. This is used to mark users as
// provisioned. A provisioned user is a user defined in the config file. // provisioned. A provisioned user is a user defined in the config file.
func (a *Manager) ChangeProvisioned(username string, provisioned bool) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.changeProvisionedTx(tx, username, provisioned)
})
}
func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error { func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error {
if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil { if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil {
return err return err
@@ -1670,7 +1702,7 @@ func (a *Manager) Tiers() ([]*Tier, error) {
tiers := make([]*Tier, 0) tiers := make([]*Tier, 0)
for { for {
tier, err := a.readTier(rows) tier, err := a.readTier(rows)
if err == ErrTierNotFound { if errors.Is(err, ErrTierNotFound) {
break break
} else if err != nil { } else if err != nil {
return nil, err return nil, err

View File

@@ -4,7 +4,6 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v74"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"net/netip" "net/netip"
@@ -164,8 +163,8 @@ func TestManager_AddUser_And_Query(t *testing.T) {
require.Nil(t, a.ChangeBilling("user", &Billing{ require.Nil(t, a.ChangeBilling("user", &Billing{
StripeCustomerID: "acct_123", StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123", StripeSubscriptionID: "sub_123",
StripeSubscriptionStatus: stripe.SubscriptionStatusActive, StripeSubscriptionStatus: "active",
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, StripeSubscriptionInterval: "month",
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour), StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
StripeSubscriptionCancelAt: time.Unix(0, 0), StripeSubscriptionCancelAt: time.Unix(0, 0),
})) }))
@@ -1163,7 +1162,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
// Re-open the DB (second app start) // Re-open the DB (second app start)
require.Nil(t, a.db.Close()) require.Nil(t, a.db.Close())
conf.Users = []*User{ conf.Users = []*User{
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, {Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
} }
conf.Access = map[string][]*Grant{ conf.Access = map[string][]*Grant{
"philuser": { "philuser": {
@@ -1209,6 +1208,9 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value) require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value)
require.Equal(t, "Another token", tokens[1].Label) require.Equal(t, "Another token", tokens[1].Label)
// Try changing provisioned user's password
require.Error(t, a.ChangePassword("philuser", "new-pass", false))
// Re-open the DB again (third app start) // Re-open the DB again (third app start)
require.Nil(t, a.db.Close()) require.Nil(t, a.db.Close())
conf.Users = []*User{} conf.Users = []*User{}
@@ -1290,7 +1292,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
// Re-open the DB (second app start) // Re-open the DB (second app start)
require.Nil(t, a.db.Close()) require.Nil(t, a.db.Close())
conf.Users = []*User{ conf.Users = []*User{
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, {Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
} }
conf.Access = map[string][]*Grant{ conf.Access = map[string][]*Grant{
"philuser": { "philuser": {
@@ -1306,7 +1308,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
require.Len(t, users, 2) require.Len(t, users, 2)
require.Equal(t, "philuser", users[0].Name) require.Equal(t, "philuser", users[0].Name)
require.Equal(t, RoleUser, users[0].Role) require.Equal(t, RoleUser, users[0].Role)
require.Equal(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
require.True(t, users[0].Provisioned) // Updated to provisioned! require.True(t, users[0].Provisioned) // Updated to provisioned!
grants, err = a.Grants("philuser") grants, err = a.Grants("philuser")

View File

@@ -2,8 +2,8 @@ package user
import ( import (
"errors" "errors"
"github.com/stripe/stripe-go/v74"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"net/netip" "net/netip"
"strings" "strings"
"time" "time"
@@ -140,8 +140,8 @@ type Stats struct {
type Billing struct { type Billing struct {
StripeCustomerID string StripeCustomerID string
StripeSubscriptionID string StripeSubscriptionID string
StripeSubscriptionStatus stripe.SubscriptionStatus StripeSubscriptionStatus payments.SubscriptionStatus
StripeSubscriptionInterval stripe.PriceRecurringInterval StripeSubscriptionInterval payments.PriceRecurringInterval
StripeSubscriptionPaidUntil time.Time StripeSubscriptionPaidUntil time.Time
StripeSubscriptionCancelAt time.Time StripeSubscriptionCancelAt time.Time
} }
@@ -249,10 +249,13 @@ var (
ErrInvalidArgument = errors.New("invalid argument") ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists") ErrUserExists = errors.New("user already exists")
ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate") ErrPasswordHashInvalid = errors.New("password hash must be a bcrypt hash, use 'ntfy user hash' to generate")
ErrPasswordHashWeak = errors.New("password hash too weak, use 'ntfy user hash' to generate")
ErrTierNotFound = errors.New("tier not found") ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found") ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number not found") ErrPhoneNumberNotFound = errors.New("phone number not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit") ErrTooManyReservations = errors.New("new tier has lower reservation limit")
ErrPhoneNumberExists = errors.New("phone number already exists") ErrPhoneNumberExists = errors.New("phone number already exists")
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
) )

View File

@@ -41,10 +41,16 @@ func AllowedTier(tier string) bool {
} }
// ValidPasswordHash checks if the given password hash is a valid bcrypt hash // ValidPasswordHash checks if the given password hash is a valid bcrypt hash
func ValidPasswordHash(hash string) error { func ValidPasswordHash(hash string, minCost int) error {
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") { if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
return ErrPasswordHashInvalid return ErrPasswordHashInvalid
} }
cost, err := bcrypt.Cost([]byte(hash))
if err != nil { // Check if the hash is valid (length, format, etc.)
return err
} else if cost < minCost {
return ErrPasswordHashWeak
}
return nil return nil
} }

31
web/package-lock.json generated
View File

@@ -3066,13 +3066,13 @@
} }
}, },
"node_modules/@types/babel__traverse": { "node_modules/@types/babel__traverse": {
"version": "7.20.7", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.20.7" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
@@ -3819,9 +3819,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.44.0", "version": "3.45.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.0.tgz",
"integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==", "integrity": "sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4112,9 +4112,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.193", "version": "1.5.195",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.193.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.195.tgz",
"integrity": "sha512-eePuBZXM9OVCwfYUhd2OzESeNGnWmLyeu0XAEjf7xjijNjHFdeJSzuRUGN4ueT2tEYo5YqjHramKEFxz67p3XA==", "integrity": "sha512-URclP0iIaDUzqcAyV1v2PgduJ9N0IdXmWsnPzPfelvBmjmZzEy6xJcjb1cXj+TbYqXgtLrjHEoaSIdTYhw4ezg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -6045,16 +6045,15 @@
} }
}, },
"node_modules/jake": { "node_modules/jake": {
"version": "10.9.2", "version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"async": "^3.2.3", "async": "^3.2.6",
"chalk": "^4.0.2",
"filelist": "^1.0.4", "filelist": "^1.0.4",
"minimatch": "^3.1.2" "picocolors": "^1.1.1"
}, },
"bin": { "bin": {
"jake": "bin/cli.js" "jake": "bin/cli.js"

View File

@@ -9,6 +9,7 @@ var config = {
base_url: window.location.origin, // Change to test against a different server base_url: window.location.origin, // Change to test against a different server
app_root: "/", app_root: "/",
enable_login: true, enable_login: true,
require_login: true,
enable_signup: true, enable_signup: true,
enable_payments: false, enable_payments: false,
enable_reservations: true, enable_reservations: true,

View File

@@ -212,6 +212,7 @@
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
"account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Call", "account_basics_phone_numbers_dialog_channel_call": "Call",
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
"account_usage_title": "Usage", "account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}", "account_usage_of_limit": "of {{limit}}",
"account_usage_unlimited": "Unlimited", "account_usage_unlimited": "Unlimited",
@@ -291,6 +292,7 @@
"account_tokens_table_current_session": "Current browser session", "account_tokens_table_current_session": "Current browser session",
"account_tokens_table_copied_to_clipboard": "Access token copied", "account_tokens_table_copied_to_clipboard": "Access token copied",
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token", "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Cannot edit or delete provisioned token",
"account_tokens_table_create_token_button": "Create access token", "account_tokens_table_create_token_button": "Create access token",
"account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup", "account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup",
"account_tokens_dialog_title_create": "Create access token", "account_tokens_dialog_title_create": "Create access token",

View File

@@ -309,5 +309,100 @@
"account_delete_dialog_button_cancel": "Cancelar", "account_delete_dialog_button_cancel": "Cancelar",
"account_upgrade_dialog_cancel_warning": "Isto irá <strong>cancelar a sua assinatura</strong>, e fazer downgrade da sua conta em {{date}}. Nessa data, tópicos reservados bem como mensagens guardadas no servidor <strong>serão eliminados</strong>.", "account_upgrade_dialog_cancel_warning": "Isto irá <strong>cancelar a sua assinatura</strong>, e fazer downgrade da sua conta em {{date}}. Nessa data, tópicos reservados bem como mensagens guardadas no servidor <strong>serão eliminados</strong>.",
"account_upgrade_dialog_proration_info": "<strong>Proporção</strong>: Quando atualizar entre planos pagos, a diferença de preço será <strong>debitada imediatamente</strong>. Quando efetuar um downgrade para um escalão inferior, o saldo disponível será usado para futuros períodos de faturação.", "account_upgrade_dialog_proration_info": "<strong>Proporção</strong>: Quando atualizar entre planos pagos, a diferença de preço será <strong>debitada imediatamente</strong>. Quando efetuar um downgrade para um escalão inferior, o saldo disponível será usado para futuros períodos de faturação.",
"prefs_users_description_no_sync": "Utilizadores e palavras-passe não estão sincronizados com a sua conta." "prefs_users_description_no_sync": "Utilizadores e palavras-passe não estão sincronizados com a sua conta.",
"account_upgrade_dialog_reservations_warning_one": "O nível selecionado permite menos tópicos reservados do que o nível atual. Antes de alterar o seu nível, <strong>apague pelo menos uma reserva</strong>. Pode remover reservas nas <Link>Configurações</Link>.",
"account_upgrade_dialog_reservations_warning_other": "O nível selecionado permite menos tópicos reservados do que o seu nível atual. Antes de mudar o seu nível, <strong>por favor apague ao menos {{count}} reservas</strong>. Pode remover reservas nas <Link>Configurações</Link>.",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tópico reservado",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} tópicos reservados",
"account_upgrade_dialog_tier_features_no_reservations": "Sem tópicos reservados",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensagen diária",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} mensagens diárias",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} email diário",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} emails diários",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} chamadas diárias",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} chamadas telefônicas diárias",
"account_upgrade_dialog_tier_features_no_calls": "Nenhuma chamada",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} por ficheiro",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} armazenamento total",
"account_upgrade_dialog_tier_price_per_month": "mês",
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} por ano. Cobrado mensalmente.",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} cobrado anualmente. Gravar {{save}}.",
"account_upgrade_dialog_tier_selected_label": "Selecionado",
"account_upgrade_dialog_tier_current_label": "Atual",
"account_upgrade_dialog_billing_contact_email": "Para questões de cobrança, <Link>entre em contacto conosco</Link> diretamente.",
"account_upgrade_dialog_billing_contact_website": "Para perguntas sobre o faturamento, consulte o nosso <Link>website</Link>.",
"account_upgrade_dialog_button_cancel": "Cancelar",
"account_upgrade_dialog_button_redirect_signup": "Cadastre-se agora",
"account_upgrade_dialog_button_pay_now": "Pague agora para assinar",
"account_upgrade_dialog_button_cancel_subscription": "Cancelar assinatura",
"account_upgrade_dialog_button_update_subscription": "Atualizar assinatura",
"account_tokens_title": "Tokens de Acesso",
"account_tokens_description": "Use tokens de acesso ao publicar e assinar por meio da API ntfy, para que não precise enviar as credenciais da sua conta. Consulte a <Link>documentação</Link> para saber mais.",
"account_tokens_table_token_header": "Token",
"account_tokens_table_label_header": "Rótulo",
"account_tokens_table_last_access_header": "Último acesso",
"account_tokens_table_expires_header": "Expira",
"account_tokens_table_never_expires": "Nunca expira",
"account_tokens_table_current_session": "Sessão atual do navegador",
"account_tokens_table_copied_to_clipboard": "Token de acesso copiado",
"account_tokens_table_cannot_delete_or_edit": "Não é possível editar ou apagar o token da sessão atual",
"account_tokens_table_create_token_button": "Criar token de acesso",
"account_tokens_table_last_origin_tooltip": "Do endereço IP {{ip}}, clique para pesquisar",
"account_tokens_dialog_title_create": "Criar token de acesso",
"account_tokens_dialog_title_edit": "Editar token de acesso",
"account_tokens_dialog_title_delete": "Apagar token de acesso",
"account_tokens_dialog_label": "Rótulo, por exemplo, notificações de Radarr",
"account_tokens_dialog_button_create": "Criar token",
"account_tokens_dialog_button_update": "Atualizar token",
"account_tokens_dialog_button_cancel": "Cancelar",
"account_tokens_dialog_expires_label": "O token de acesso expira em",
"account_tokens_dialog_expires_unchanged": "Deixar a data de validade inalterada",
"account_tokens_dialog_expires_x_hours": "O token expira em {{hours}} horas",
"account_tokens_dialog_expires_x_days": "O token expira em {{days}} dias",
"account_tokens_dialog_expires_never": "O token nunca expira",
"account_tokens_delete_dialog_title": "Apagar token de acesso",
"account_tokens_delete_dialog_description": "Antes de apagar um token de acesso, certifique-se de que nenhuma aplicação ou script esteja usando-lo ativamente. <strong>Esta ação não pode ser desfeita</strong>.",
"account_tokens_delete_dialog_submit_button": "Apagar token permanentemente",
"prefs_notifications_web_push_title": "Notificações em segundo plano",
"prefs_notifications_web_push_enabled_description": "As notificações são recebidas mesmo quando a aplicação Web não está em execução (via Web Push)",
"prefs_notifications_web_push_disabled_description": "As notificações são recebidas quando a aplicação Web está em execução (via WebSocket)",
"prefs_notifications_web_push_enabled": "Ativado para {{server}}",
"prefs_notifications_web_push_disabled": "Desativado",
"prefs_users_table_cannot_delete_or_edit": "Não é possível apagar ou editar o utilizador conectado",
"prefs_appearance_theme_title": "Tema",
"prefs_appearance_theme_system": "Sistema (padrão)",
"prefs_appearance_theme_dark": "Modo escuro",
"prefs_appearance_theme_light": "Modo claro",
"prefs_reservations_title": "Tópicos reservados",
"prefs_reservations_description": "Pode reservar nomes de tópicos para uso pessoal aqui. A reserva de um tópico lhe dá propriedade sobre ele e permite que defina permissões de acesso para outros utilizadores sobre o tópico.",
"prefs_reservations_limit_reached": "Atingiu o seu limite de tópicos reservados.",
"prefs_reservations_add_button": "Adicionar tópico reservado",
"prefs_reservations_edit_button": "Editar o acesso ao tópico",
"prefs_reservations_delete_button": "Redefinir o acesso ao tópico",
"prefs_reservations_table": "Tabela de tópicos reservados",
"prefs_reservations_table_topic_header": "Tópico",
"prefs_reservations_table_access_header": "Acesso",
"prefs_reservations_table_everyone_deny_all": "Somente eu posso publicar e me inscrever",
"prefs_reservations_table_everyone_read_only": "Posso publicar e me inscrever, todos podem se inscrever",
"prefs_reservations_table_everyone_write_only": "Posso publicar e me inscrever, todos podem publicar",
"prefs_reservations_table_everyone_read_write": "Todos podem publicar e se inscreverem",
"prefs_reservations_table_not_subscribed": "Não inscrito",
"prefs_reservations_table_click_to_subscribe": "Clique para se inscrever",
"prefs_reservations_dialog_title_add": "Reservar tópico",
"prefs_reservations_dialog_title_edit": "Editar tópico reservado",
"prefs_reservations_dialog_title_delete": "Apagar reserva de tópico",
"prefs_reservations_dialog_description": "A reserva de um tópico lhe dá propriedade sobre ele e permite definir permissões de acesso para outros utilizadores sobre o tópico.",
"prefs_reservations_dialog_topic_label": "Tópico",
"prefs_reservations_dialog_access_label": "Acesso",
"reservation_delete_dialog_description": "A remoção de uma reserva abre mão da propriedade sobre o tópico e permite que outros o reservem. Pode manter ou apagar as mensagens e os anexos existentes.",
"reservation_delete_dialog_action_keep_title": "Manter mensagens e anexos em cache",
"reservation_delete_dialog_action_keep_description": "As mensagens e os anexos armazenados em cache no servidor ficarão visíveis publicamente para as pessoas que souberem o nome do tópico.",
"reservation_delete_dialog_action_delete_title": "Apagar mensagens e anexos armazenados em cache",
"reservation_delete_dialog_action_delete_description": "As mensagens e os anexos armazenados em cache serão apagados permanentemente. Esta ação não pode ser desfeita.",
"reservation_delete_dialog_submit_button": "Apagar reserva",
"error_boundary_button_reload_ntfy": "Recarregar ntfy",
"web_push_subscription_expiring_title": "As notificações serão pausadas",
"web_push_subscription_expiring_body": "Abra o ntfy para continuar recebendo notificações",
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web"
} }

View File

@@ -13,7 +13,7 @@
"nav_button_documentation": "ஆவணப்படுத்துதல்", "nav_button_documentation": "ஆவணப்படுத்துதல்",
"nav_button_publish_message": "அறிவிப்பை வெளியிடுங்கள்", "nav_button_publish_message": "அறிவிப்பை வெளியிடுங்கள்",
"alert_not_supported_description": "உங்கள் உலாவியில் அறிவிப்புகள் ஆதரிக்கப்படவில்லை", "alert_not_supported_description": "உங்கள் உலாவியில் அறிவிப்புகள் ஆதரிக்கப்படவில்லை",
"alert_not_supported_context_description": "அறிவிப்புகள் HTTP களில் மட்டுமே ஆதரிக்கப்படுகின்றன. இது <mdnlink> அறிவிப்புகள் பநிஇ </mdnlink> இன் வரம்பு.", "alert_not_supported_context_description": "அறிவிப்புகள் HTTP களில் மட்டுமே ஆதரிக்கப்படுகின்றன. இது<mdnLink>அறிவிப்புகள் பநிஇ</mdnLink> இன் வரம்பு.",
"notifications_list": "அறிவிப்புகள் பட்டியல்", "notifications_list": "அறிவிப்புகள் பட்டியல்",
"notifications_delete": "நீக்கு", "notifications_delete": "நீக்கு",
"notifications_copied_to_clipboard": "இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது", "notifications_copied_to_clipboard": "இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது",
@@ -76,7 +76,7 @@
"publish_dialog_chip_email_label": "மின்னஞ்சலுக்கு அனுப்பவும்", "publish_dialog_chip_email_label": "மின்னஞ்சலுக்கு அனுப்பவும்",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "சரிபார்க்கப்பட்ட தொலைபேசி எண்கள் இல்லை", "publish_dialog_chip_call_no_verified_numbers_tooltip": "சரிபார்க்கப்பட்ட தொலைபேசி எண்கள் இல்லை",
"publish_dialog_chip_attach_url_label": "முகவரி மூலம் கோப்பை இணைக்கவும்", "publish_dialog_chip_attach_url_label": "முகவரி மூலம் கோப்பை இணைக்கவும்",
"publish_dialog_details_examples_description": "எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து <ock இணைப்பு> ஆவணங்கள் </டாக்ச் இணைப்பு> ஐப் பார்க்கவும்.", "publish_dialog_details_examples_description": "எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து <docsLink>ஆவணங்கள் </docsLink> ஐப் பார்க்கவும்.",
"publish_dialog_chip_attach_file_label": "உள்ளக கோப்பை இணைக்கவும்", "publish_dialog_chip_attach_file_label": "உள்ளக கோப்பை இணைக்கவும்",
"publish_dialog_chip_delay_label": "நேரந்தவறுகை வழங்கல்", "publish_dialog_chip_delay_label": "நேரந்தவறுகை வழங்கல்",
"publish_dialog_chip_topic_label": "தலைப்பை மாற்றவும்", "publish_dialog_chip_topic_label": "தலைப்பை மாற்றவும்",
@@ -133,10 +133,10 @@
"account_usage_cannot_create_portal_session": "பட்டியலிடல் போர்ட்டலைத் திறக்க முடியவில்லை", "account_usage_cannot_create_portal_session": "பட்டியலிடல் போர்ட்டலைத் திறக்க முடியவில்லை",
"account_delete_title": "கணக்கை நீக்கு", "account_delete_title": "கணக்கை நீக்கு",
"account_delete_description": "உங்கள் கணக்கை நிரந்தரமாக நீக்கவும்", "account_delete_description": "உங்கள் கணக்கை நிரந்தரமாக நீக்கவும்",
"account_upgrade_dialog_cancel_warning": "இது <strong> உங்கள் சந்தாவை ரத்துசெய்யும் </strong>, மேலும் உங்கள் கணக்கை {{date} at இல் தரமிறக்குகிறது. அந்த தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாக சேமிக்கப்பட்ட செய்திகளும் நீக்கப்படும் </strong>.", "account_upgrade_dialog_cancel_warning": "இது <strong> உங்கள் சந்தாவை ரத்துசெய்யும் </strong>, மேலும் உங்கள் கணக்கை {{date}} இல் தரமிறக்குகிறது. அந்தத் தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாகச் சேமிக்கப்பட்ட செய்திகளும் <strong>நீக்கப்படும் </strong>.",
"account_upgrade_dialog_proration_info": "<strong> புரோரேசன் </strong>: கட்டணத் திட்டங்களுக்கு இடையில் மேம்படுத்தும்போது, விலை வேறுபாடு <strong> உடனடியாக கட்டணம் வசூலிக்கப்படும் </strong>. குறைந்த அடுக்குக்கு தரமிறக்கும்போது, எதிர்கால பட்டியலிடல் காலங்களுக்கு செலுத்த இருப்பு பயன்படுத்தப்படும்.", "account_upgrade_dialog_proration_info": "<strong> புரோரேசன் </strong>: கட்டணத் திட்டங்களுக்கு இடையில் மேம்படுத்தும்போது, விலை வேறுபாடு <strong> உடனடியாக கட்டணம் வசூலிக்கப்படும் </strong>. குறைந்த அடுக்குக்கு தரமிறக்கும்போது, எதிர்கால பட்டியலிடல் காலங்களுக்கு செலுத்த இருப்பு பயன்படுத்தப்படும்.",
"account_upgrade_dialog_reservations_warning_one": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கை விட குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தது ஒரு முன்பதிவை நீக்கு </strong>. <இணைப்பு> அமைப்புகள் </இணைப்பு> இல் முன்பதிவுகளை அகற்றலாம்.", "account_upgrade_dialog_reservations_warning_one": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தது ஒரு முன்பதிவை நீக்கு </strong>. <Link>அமைப்புகள்</Link> இல் முன்பதிவுகளை அகற்றலாம்.",
"account_upgrade_dialog_reservations_warning_other": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கை விட குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு </strong> ஐ நீக்கவும். <இணைப்பு> அமைப்புகள் </இணைப்பு> இல் முன்பதிவுகளை அகற்றலாம்.", "account_upgrade_dialog_reservations_warning_other": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு </strong> ஐ நீக்கவும். <Link>அமைப்புகள்</Link> இல் முன்பதிவுகளை அகற்றலாம்.",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} ஒதுக்கப்பட்ட தலைப்புகள்", "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} ஒதுக்கப்பட்ட தலைப்புகள்",
"account_upgrade_dialog_tier_features_no_reservations": "ஒதுக்கப்பட்ட தலைப்புகள் இல்லை", "account_upgrade_dialog_tier_features_no_reservations": "ஒதுக்கப்பட்ட தலைப்புகள் இல்லை",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} நாள்தோறும் செய்தி", "account_upgrade_dialog_tier_features_messages_one": "{{messages}} நாள்தோறும் செய்தி",
@@ -153,14 +153,14 @@
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} ஆண்டுதோறும் கட்டணம் செலுத்தப்படுகிறது. {{save}} சேமி.", "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} ஆண்டுதோறும் கட்டணம் செலுத்தப்படுகிறது. {{save}} சேமி.",
"account_upgrade_dialog_tier_selected_label": "தேர்ந்தெடுக்கப்பட்டது", "account_upgrade_dialog_tier_selected_label": "தேர்ந்தெடுக்கப்பட்டது",
"account_upgrade_dialog_tier_current_label": "மின்னோட்ட்ம், ஓட்டம்", "account_upgrade_dialog_tier_current_label": "மின்னோட்ட்ம், ஓட்டம்",
"account_upgrade_dialog_billing_contact_email": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து <இணைப்பு> எங்களை தொடர்பு கொள்ளவும் </இணைப்பு> நேரடியாக.", "account_upgrade_dialog_billing_contact_email": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து <Link>எங்களைத் தொடர்பு கொள்ளவும் </Link>நேரடியாக.",
"account_upgrade_dialog_button_cancel": "ரத்துசெய்", "account_upgrade_dialog_button_cancel": "ரத்துசெய்",
"account_upgrade_dialog_billing_contact_website": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் <இணைப்பு> வலைத்தளம் </இணைப்பு> ஐப் பார்க்கவும்.", "account_upgrade_dialog_billing_contact_website": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் <Link>வலைத்தளம்</Link> ஐப் பார்க்கவும்.",
"account_upgrade_dialog_button_redirect_signup": "இப்போது பதிவுபெறுக", "account_upgrade_dialog_button_redirect_signup": "இப்போது பதிவுபெறுக",
"account_upgrade_dialog_button_pay_now": "இப்போது பணம் செலுத்தி குழுசேரவும்", "account_upgrade_dialog_button_pay_now": "இப்போது பணம் செலுத்தி குழுசேரவும்",
"account_upgrade_dialog_button_cancel_subscription": "சந்தாவை ரத்துசெய்", "account_upgrade_dialog_button_cancel_subscription": "சந்தாவை ரத்துசெய்",
"account_tokens_title": "டோக்கன்களை அணுகவும்", "account_tokens_title": "டோக்கன்களை அணுகவும்",
"account_tokens_description": "NTFY பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும் போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய <இணைப்பு> ஆவணங்கள் </இணைப்பு> ஐப் பாருங்கள்.", "account_tokens_description": "NTFY பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும்போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய <Link> ஆவணங்கள்</Link> ஐப் பாருங்கள்.",
"account_upgrade_dialog_button_update_subscription": "சந்தாவைப் புதுப்பிக்கவும்", "account_upgrade_dialog_button_update_subscription": "சந்தாவைப் புதுப்பிக்கவும்",
"account_tokens_table_token_header": "கிள்ளாக்கு", "account_tokens_table_token_header": "கிள்ளாக்கு",
"account_tokens_table_label_header": "சிட்டை", "account_tokens_table_label_header": "சிட்டை",
@@ -216,7 +216,7 @@
"prefs_notifications_web_push_title": "பின்னணி அறிவிப்புகள்", "prefs_notifications_web_push_title": "பின்னணி அறிவிப்புகள்",
"prefs_notifications_web_push_enabled_description": "வலை பயன்பாடு இயங்காதபோது கூட அறிவிப்புகள் பெறப்படுகின்றன (வலை புச் வழியாக)", "prefs_notifications_web_push_enabled_description": "வலை பயன்பாடு இயங்காதபோது கூட அறிவிப்புகள் பெறப்படுகின்றன (வலை புச் வழியாக)",
"prefs_notifications_web_push_disabled_description": "வலை பயன்பாடு இயங்கும்போது அறிவிப்பு பெறப்படுகிறது (வெப்சாக்கெட் வழியாக)", "prefs_notifications_web_push_disabled_description": "வலை பயன்பாடு இயங்கும்போது அறிவிப்பு பெறப்படுகிறது (வெப்சாக்கெட் வழியாக)",
"prefs_notifications_web_push_enabled": "{{server} க்கு க்கு இயக்கப்பட்டது", "prefs_notifications_web_push_enabled": "{{server}} க்கு இயக்கப்பட்டது",
"prefs_notifications_web_push_disabled": "முடக்கப்பட்டது", "prefs_notifications_web_push_disabled": "முடக்கப்பட்டது",
"prefs_users_title": "பயனர்களை நிர்வகிக்கவும்", "prefs_users_title": "பயனர்களை நிர்வகிக்கவும்",
"prefs_users_description": "உங்கள் பாதுகாக்கப்பட்ட தலைப்புகளுக்கு பயனர்களை இங்கே சேர்க்கவும்/அகற்றவும். பயனர்பெயர் மற்றும் கடவுச்சொல் உலாவியின் உள்ளக சேமிப்பகத்தில் சேமிக்கப்பட்டுள்ளன என்பதை நினைவில் கொள்க.", "prefs_users_description": "உங்கள் பாதுகாக்கப்பட்ட தலைப்புகளுக்கு பயனர்களை இங்கே சேர்க்கவும்/அகற்றவும். பயனர்பெயர் மற்றும் கடவுச்சொல் உலாவியின் உள்ளக சேமிப்பகத்தில் சேமிக்கப்பட்டுள்ளன என்பதை நினைவில் கொள்க.",
@@ -271,7 +271,7 @@
"priority_max": "அதிகபட்சம்", "priority_max": "அதிகபட்சம்",
"priority_default": "இயல்புநிலை", "priority_default": "இயல்புநிலை",
"error_boundary_title": "ஓ, NTFY செயலிழந்தது", "error_boundary_title": "ஓ, NTFY செயலிழந்தது",
"error_boundary_description": "இது வெளிப்படையாக நடக்கக்கூடாது. இதைப் பற்றி மிகவும் வருந்துகிறேன். .", "error_boundary_description": "இது நிச்சயமாக நடக்கக் கூடாது. இதுகுறித்து மிகவும் வருந்துகிறேன்.<br/>உங்களிடம் ஒரு நிமிடம் இருந்தால், தயவுசெய்து <githubLink>இதை GitHub இல் புகாரளிக்கவும்</githubLink>, அல்லது <discordLink>Discord</discordLink> அல்லது <matrixLink>Matrix</matrixLink> வழியாக எங்களுக்குத் தெரியப்படுத்தவும்.",
"error_boundary_button_copy_stack_trace": "அடுக்கு சுவடு நகலெடுக்கவும்", "error_boundary_button_copy_stack_trace": "அடுக்கு சுவடு நகலெடுக்கவும்",
"error_boundary_button_reload_ntfy": "Ntfy ஐ மீண்டும் ஏற்றவும்", "error_boundary_button_reload_ntfy": "Ntfy ஐ மீண்டும் ஏற்றவும்",
"error_boundary_stack_trace": "ச்டாக் சுவடு", "error_boundary_stack_trace": "ச்டாக் சுவடு",
@@ -349,7 +349,7 @@
"notifications_no_subscriptions_title": "உங்களிடம் இன்னும் சந்தாக்கள் இல்லை என்று தெரிகிறது.", "notifications_no_subscriptions_title": "உங்களிடம் இன்னும் சந்தாக்கள் இல்லை என்று தெரிகிறது.",
"notifications_no_subscriptions_description": "ஒரு தலைப்பை உருவாக்க அல்லது குழுசேர \"{{linktext}}\" இணைப்பைக் சொடுக்கு செய்க. அதன்பிறகு, நீங்கள் புட் அல்லது இடுகை வழியாக செய்திகளை அனுப்பலாம், மேலும் நீங்கள் இங்கே அறிவிப்புகளைப் பெறுவீர்கள்.", "notifications_no_subscriptions_description": "ஒரு தலைப்பை உருவாக்க அல்லது குழுசேர \"{{linktext}}\" இணைப்பைக் சொடுக்கு செய்க. அதன்பிறகு, நீங்கள் புட் அல்லது இடுகை வழியாக செய்திகளை அனுப்பலாம், மேலும் நீங்கள் இங்கே அறிவிப்புகளைப் பெறுவீர்கள்.",
"notifications_example": "எடுத்துக்காட்டு", "notifications_example": "எடுத்துக்காட்டு",
"notifications_more_details": "மேலும் தகவலுக்கு, </webititeLink> வலைத்தளம் </websiteLink> அல்லது <ockslink> ஆவணங்கள் </docslink> ஐப் பாருங்கள்.", "notifications_more_details": "மேலும் தகவலுக்கு, <websiteLink>வலைத்தளம் </websiteLink> அல்லது <docsLink> ஆவணங்கள் </docsLink> ஐப் பாருங்கள்.",
"display_name_dialog_title": "காட்சி பெயரை மாற்றவும்", "display_name_dialog_title": "காட்சி பெயரை மாற்றவும்",
"display_name_dialog_description": "சந்தா பட்டியலில் காட்டப்படும் தலைப்புக்கு மாற்று பெயரை அமைக்கவும். சிக்கலான பெயர்களைக் கொண்ட தலைப்புகளை மிக எளிதாக அடையாளம் காண இது உதவுகிறது.", "display_name_dialog_description": "சந்தா பட்டியலில் காட்டப்படும் தலைப்புக்கு மாற்று பெயரை அமைக்கவும். சிக்கலான பெயர்களைக் கொண்ட தலைப்புகளை மிக எளிதாக அடையாளம் காண இது உதவுகிறது.",
"display_name_dialog_placeholder": "காட்சி பெயர்", "display_name_dialog_placeholder": "காட்சி பெயர்",
@@ -399,7 +399,7 @@
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "{{discount}}% வரை சேமிக்கவும்", "account_upgrade_dialog_interval_yearly_discount_save_up_to": "{{discount}}% வரை சேமிக்கவும்",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} முன்பதிவு செய்யப்பட்ட தலைப்பு", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} முன்பதிவு செய்யப்பட்ட தலைப்பு",
"prefs_users_add_button": "பயனரைச் சேர்க்கவும்", "prefs_users_add_button": "பயனரைச் சேர்க்கவும்",
"error_boundary_unsupported_indexeddb_description": "NTFY வலை பயன்பாட்டிற்கு செயல்பட குறியீட்டு தேவை, மற்றும் உங்கள் உலாவி தனிப்பட்ட உலாவல் பயன்முறையில் IndexEDDB ஐ ஆதரிக்காது. எப்படியிருந்தாலும் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்படு, ஏனென்றால் அனைத்தும் உலாவி சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இந்த அறிவிலிமையம் இதழில் </githublink> இல் <githublink> பற்றி நீங்கள் மேலும் படிக்கலாம் அல்லது <scordlink> டிச்கார்ட் </disordlink> அல்லது <agadgaglelink> மேட்ரிக்ச் </மேட்ரிக்ச்லிங்க்> இல் எங்களுடன் பேசலாம்.", "error_boundary_unsupported_indexeddb_description": "ntfy வலை பயன்பாடு செயல்பட IndexedDB தேவை, மேலும் உங்கள் உலாவித் தனிப்பட்ட உலாவல் பயன்முறையில் IndexedDB ஐ ஆதரிக்காது.<br/><br/>இது துரதிர்ஷ்டவசமானது என்றாலும், ntfy வலை பயன்பாட்டைத் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்படுத்துவது உண்மையில் அர்த்தமற்றது, ஏனெனில் அனைத்தும் உலாவிச் சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இதைப் பற்றி நீங்கள் <githubLink>இந்த GitHub சிக்கலில் மேலும் படிக்கலாம்</githubLink>, அல்லது <discordLink>Discord</discordLink> அல்லது <matrixLink>Matrix</matrixLink> இல் எங்களுடன் பேசலாம்.",
"web_push_subscription_expiring_title": "அறிவிப்புகள் இடைநிறுத்தப்படும்", "web_push_subscription_expiring_title": "அறிவிப்புகள் இடைநிறுத்தப்படும்",
"web_push_subscription_expiring_body": "தொடர்ந்து அறிவிப்புகளைப் பெற NTFY ஐத் திறக்கவும்", "web_push_subscription_expiring_body": "தொடர்ந்து அறிவிப்புகளைப் பெற NTFY ஐத் திறக்கவும்",
"web_push_unknown_notification_title": "சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது", "web_push_unknown_notification_title": "சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது",

View File

@@ -77,7 +77,10 @@ export const maybeWithBearerAuth = (headers, token) => {
return headers; return headers;
}; };
export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) }); export const withBasicAuth = (headers, username, password) => ({
...headers,
Authorization: basicAuth(username, password),
});
export const maybeWithAuth = (headers, user) => { export const maybeWithAuth = (headers, user) => {
if (user?.password) { if (user?.password) {
@@ -270,3 +273,21 @@ export const urlB64ToUint8Array = (base64String) => {
} }
return outputArray; return outputArray;
}; };
export const copyToClipboard = (text) => {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
// Fallback to the older method if clipboard API is not supported (or on HTTP)
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", ""); // Avoid mobile keyboards from popping up
textarea.style.position = "fixed"; // Avoid scroll jump
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return Promise.resolve();
};

View File

@@ -45,7 +45,7 @@ import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material"; import { ContentCopy, Public } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import routes from "./routes"; import routes from "./routes";
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
import { Pref, PrefGroup } from "./Pref"; import { Pref, PrefGroup } from "./Pref";
import db from "../app/db"; import db from "../app/db";
@@ -100,15 +100,13 @@ const Username = () => {
<Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}> <Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
<div aria-labelledby={labelId}> <div aria-labelledby={labelId}>
{session.username()} {session.username()}
{account?.role === Role.ADMIN ? ( {account?.role === Role.ADMIN && (
<> <>
{" "} {" "}
<Tooltip title={t("account_basics_username_admin_tooltip")}> <Tooltip title={t("account_basics_username_admin_tooltip")}>
<span style={{ cursor: "default" }}>👑</span> <span style={{ cursor: "default" }}>👑</span>
</Tooltip> </Tooltip>
</> </>
) : (
""
)} )}
</div> </div>
</Pref> </Pref>
@@ -119,6 +117,7 @@ const ChangePassword = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const { account } = useContext(AccountContext);
const labelId = "prefChangePassword"; const labelId = "prefChangePassword";
const handleDialogOpen = () => { const handleDialogOpen = () => {
@@ -136,9 +135,19 @@ const ChangePassword = () => {
<Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}> <Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
</Typography> </Typography>
{!account?.provisioned ? (
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}> <IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
) : (
<Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
<span>
<IconButton disabled>
<EditIcon />
</IconButton>
</span>
</Tooltip>
)}
</div> </div>
<ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} /> <ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
</Pref> </Pref>
@@ -361,7 +370,7 @@ const PhoneNumbers = () => {
}; };
const handleCopy = (phoneNumber) => { const handleCopy = (phoneNumber) => {
navigator.clipboard.writeText(phoneNumber); copyToClipboard(phoneNumber);
setSnackOpen(true); setSnackOpen(true);
}; };
@@ -832,7 +841,7 @@ const TokensTable = (props) => {
}; };
const handleCopy = async (token) => { const handleCopy = async (token) => {
await navigator.clipboard.writeText(token); copyToClipboard(token);
setSnackOpen(true); setSnackOpen(true);
}; };
@@ -888,7 +897,7 @@ const TokensTable = (props) => {
</div> </div>
</TableCell> </TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{token.token !== session.token() && ( {token.token !== session.token() && !token.provisioned && (
<> <>
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> <IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
<EditIcon /> <EditIcon />
@@ -910,6 +919,18 @@ const TokensTable = (props) => {
</span> </span>
</Tooltip> </Tooltip>
)} )}
{token.provisioned && (
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
<span>
<IconButton disabled>
<EditIcon />
</IconButton>
<IconButton disabled>
<CloseIcon />
</IconButton>
</span>
</Tooltip>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -1048,6 +1069,7 @@ const DeleteAccount = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const { account } = useContext(AccountContext);
const handleDialogOpen = () => { const handleDialogOpen = () => {
setDialogKey((prev) => prev + 1); setDialogKey((prev) => prev + 1);
@@ -1061,9 +1083,19 @@ const DeleteAccount = () => {
return ( return (
<Pref title={t("account_delete_title")} description={t("account_delete_description")}> <Pref title={t("account_delete_title")} description={t("account_delete_description")}>
<div> <div>
{!account?.provisioned ? (
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}> <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
{t("account_delete_title")} {t("account_delete_title")}
</Button> </Button>
) : (
<Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
<span>
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} disabled>
{t("account_delete_title")}
</Button>
</span>
</Tooltip>
)}
</div> </div>
<DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} /> <DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
</Pref> </Pref>

View File

@@ -23,6 +23,7 @@ import Account from "./Account";
import initI18n from "../app/i18n"; // Translations! import initI18n from "../app/i18n"; // Translations!
import prefs, { THEME } from "../app/Prefs"; import prefs, { THEME } from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider"; import RTLCacheProvider from "./RTLCacheProvider";
import session from "../app/Session";
initI18n(); initI18n();
@@ -45,7 +46,6 @@ const darkModeEnabled = (prefersDarkMode, themePreference) => {
const App = () => { const App = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const languageDir = i18n.dir(); const languageDir = i18n.dir();
const [account, setAccount] = useState(null); const [account, setAccount] = useState(null);
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]); const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
@@ -60,6 +60,12 @@ const App = () => {
document.dir = languageDir; document.dir = languageDir;
}, [i18n.language, languageDir]); }, [i18n.language, languageDir]);
useEffect(() => {
if (!session.exists() && config.require_login && window.location.pathname !== routes.login) {
window.location.href = routes.login;
}
}, []);
return ( return (
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<RTLCacheProvider> <RTLCacheProvider>

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import StackTrace from "stacktrace-js"; import StackTrace from "stacktrace-js";
import { CircularProgress, Link, Button } from "@mui/material"; import { CircularProgress, Link, Button } from "@mui/material";
import { Trans, withTranslation } from "react-i18next"; import { Trans, withTranslation } from "react-i18next";
import { copyToClipboard } from "../app/utils";
class ErrorBoundaryImpl extends React.Component { class ErrorBoundaryImpl extends React.Component {
constructor(props) { constructor(props) {
@@ -64,7 +65,7 @@ class ErrorBoundaryImpl extends React.Component {
stack += `${this.state.niceStack}\n\n`; stack += `${this.state.niceStack}\n\n`;
} }
stack += `${this.state.originalStack}\n`; stack += `${this.state.originalStack}\n`;
navigator.clipboard.writeText(stack); copyToClipboard(stack);
} }
renderUnsupportedIndexedDB() { renderUnsupportedIndexedDB() {

View File

@@ -26,7 +26,16 @@ import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { useRemark } from "react-remark"; import { useRemark } from "react-remark";
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { formatBytes, formatShortDateTime, maybeActionErrors, openUrl, shortUrl, topicShortUrl, unmatchedTags } from "../app/utils"; import {
copyToClipboard,
formatBytes,
formatShortDateTime,
maybeActionErrors,
openUrl,
shortUrl,
topicShortUrl,
unmatchedTags,
} from "../app/utils";
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils"; import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles"; import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
@@ -239,7 +248,7 @@ const NotificationItem = (props) => {
await subscriptionManager.markNotificationRead(notification.id); await subscriptionManager.markNotificationRead(notification.id);
}; };
const handleCopy = (s) => { const handleCopy = (s) => {
navigator.clipboard.writeText(s); copyToClipboard(s);
props.onShowSnack(); props.onShowSnack();
}; };
const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000; const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000;