Compare commits

...

74 Commits

Author SHA1 Message Date
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
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
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
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
binwiederhier
27151d1cac Fix tests 2025-07-31 11:35:21 +02:00
binwiederhier
a1c6dd2085 Comments 2025-07-31 11:28:27 +02:00
binwiederhier
8f930acfb8 Docs 2025-07-31 11:20:03 +02:00
binwiederhier
08d44703c3 Tiny fixes 2025-07-31 10:46:41 +02:00
binwiederhier
82282419fe Self-review 2025-07-31 10:34:02 +02:00
binwiederhier
e290d1307f Tests 2025-07-31 10:26:53 +02:00
binwiederhier
747c5c9fff Docs 2025-07-31 10:08:25 +02:00
binwiederhier
9f987e66fa Make sure tokens are updated instead of deleted/re-added 2025-07-31 08:36:05 +02:00
binwiederhier
b91ff5f0b5 Move stuff to util.go 2025-07-31 07:33:11 +02:00
binwiederhier
23ec7702fc Add "auth-tokens" 2025-07-31 07:08:35 +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
binwiederhier
149c13e9d8 Update config to reference declarative users 2025-07-27 22:38:12 +02:00
binwiederhier
07e9670a09 Fix bug in test 2025-07-27 22:33:29 +02:00
binwiederhier
0e67228605 Docs 2025-07-27 17:18:06 +02:00
binwiederhier
2578236d8d Docs 2025-07-27 17:10:37 +02:00
binwiederhier
fe545423c5 Change to auth-(users|access), upgrade manually added users to provision users 2025-07-27 12:10:16 +02:00
binwiederhier
f3c67f1d71 Refuse to update manually created users 2025-07-27 11:02:34 +02:00
binwiederhier
27b3a89247 Merge branch 'main' of github.com:binwiederhier/ntfy into predefined-users 2025-07-27 10:16:55 +02:00
binwiederhier
1470afb715 Make templateMode more understandable 2025-07-27 10:15:48 +02:00
binwiederhier
b495a744c9 Merge branch 'main' of github.com:binwiederhier/ntfy into predefined-users 2025-07-27 09:36:45 +02:00
Philipp C. Heckel
d2b5917e2b Merge pull request #1404 from wunter8/inline-template-newlines
allow newlines in in-line go templates
2025-07-27 09:36:24 +02:00
binwiederhier
52ca98611c Merge branch 'main' of github.com:binwiederhier/ntfy into predefined-users 2025-07-27 09:33:49 +02: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
Hunter Kehoe
0d36ab8af3 allow newlines in in-line go templates 2025-07-27 00:01:51 -06:00
binwiederhier
141ddb3a51 Comments 2025-07-26 12:20:11 +02:00
binwiederhier
f99801a2e6 Add "ntfy user hash" 2025-07-26 12:14:21 +02:00
binwiederhier
4457e9e26f Migration 2025-07-26 11:16:33 +02:00
Daniel Krol
4eb7dc563c Add Ntfy for Sandstorm to integrations.md 2025-07-22 18:50:18 -04:00
Philipp C. Heckel
269373d75d Merge pull request #1398 from emmaexe/add-ntfydesktop-integration
[docs] Add Ntfy Desktop to integrations
2025-07-22 12:57:01 +02:00
Emma
ef275ac0c1 Add Ntfy Desktop to integrations 2025-07-22 11:54:06 +02:00
binwiederhier
f59df0f40a Works 2025-07-21 17:44:00 +02:00
timof
214f70e62f Merge branch 'binwiederhier:main' into main 2025-07-21 16:52:25 +04:00
binwiederhier
51af114b2e Merge branch 'main' of github.com:binwiederhier/ntfy into predefined-users 2025-07-21 11:57:14 +02:00
Philipp C. Heckel
83bf9d4d6c Merge pull request #1390 from binwiederhier/template-dir
Advanced message templating: Sprig functions, pre-defined templates, custom templates via `template-dir`
2025-07-21 11:56:28 +02: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
c0b5151bae Predefined users 2025-07-10 20:50:29 +02:00
binwiederhier
efef587671 WIP: Predefined users 2025-07-07 22:36:01 +02:00
binwiederhier
5ccc131e73 Derp 2025-07-04 06:41:14 +02:00
54 changed files with 2730 additions and 724 deletions

View File

@@ -232,7 +232,7 @@ cli-deps-update:
go get -u go get -u
go install honnef.co/go/tools/cmd/staticcheck@latest go install honnef.co/go/tools/cmd/staticcheck@latest
go install golang.org/x/lint/golint@latest go install golang.org/x/lint/golint@latest
go install github.com/goreleaser/goreleaser@latest go install github.com/goreleaser/goreleaser/v2@latest
cli-build-results: cli-build-results:
cat dist/config.yaml cat dist/config.yaml

View File

@@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
return err return err
} }
u, err := manager.User(username) u, err := manager.User(username)
if err == user.ErrUserNotFound { if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
} else if u.Role == user.RoleAdmin { } else if u.Role == user.RoleAdmin {
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username) return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
} }
@@ -114,13 +116,13 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
return err return err
} }
if permission.IsReadWrite() { if permission.IsReadWrite() {
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic) fmt.Fprintf(c.App.Writer, "granted read-write access to topic %s\n\n", topic)
} else if permission.IsRead() { } else if permission.IsRead() {
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic) fmt.Fprintf(c.App.Writer, "granted read-only access to topic %s\n\n", topic)
} else if permission.IsWrite() { } else if permission.IsWrite() {
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic) fmt.Fprintf(c.App.Writer, "granted write-only access to topic %s\n\n", topic)
} else { } else {
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic) fmt.Fprintf(c.App.Writer, "revoked all access to topic %s\n\n", topic)
} }
return showUserAccess(c, manager, username) return showUserAccess(c, manager, username)
} }
@@ -138,7 +140,7 @@ func resetAllAccess(c *cli.Context, manager *user.Manager) error {
if err := manager.ResetAccess("", ""); err != nil { if err := manager.ResetAccess("", ""); err != nil {
return err return err
} }
fmt.Fprintln(c.App.ErrWriter, "reset access for all users") fmt.Fprintln(c.App.Writer, "reset access for all users")
return nil return nil
} }
@@ -146,7 +148,7 @@ func resetUserAccess(c *cli.Context, manager *user.Manager, username string) err
if err := manager.ResetAccess(username, ""); err != nil { if err := manager.ResetAccess(username, ""); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username) fmt.Fprintf(c.App.Writer, "reset access for user %s\n\n", username)
return showUserAccess(c, manager, username) return showUserAccess(c, manager, username)
} }
@@ -154,7 +156,7 @@ func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string
if err := manager.ResetAccess(username, topic); err != nil { if err := manager.ResetAccess(username, topic); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic) fmt.Fprintf(c.App.Writer, "reset access for user %s and topic %s\n\n", username, topic)
return showUserAccess(c, manager, username) return showUserAccess(c, manager, username)
} }
@@ -175,7 +177,7 @@ func showAllAccess(c *cli.Context, manager *user.Manager) error {
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error { func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
users, err := manager.User(username) users, err := manager.User(username)
if err == user.ErrUserNotFound { if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} else if err != nil { } else if err != nil {
return err return err
@@ -193,34 +195,42 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
if u.Tier != nil { if u.Tier != nil {
tier = u.Tier.Name tier = u.Tier.Name
} }
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier) provisioned := ""
if u.Provisioned {
provisioned = ", server config"
}
fmt.Fprintf(c.App.Writer, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned)
if u.Role == user.RoleAdmin { if u.Role == user.RoleAdmin {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") fmt.Fprintf(c.App.Writer, "- read-write access to all topics (admin role)\n")
} else if len(grants) > 0 { } else if len(grants) > 0 {
for _, grant := range grants { for _, grant := range grants {
if grant.Allow.IsReadWrite() { grantProvisioned := ""
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern) if grant.Provisioned {
} else if grant.Allow.IsRead() { grantProvisioned = " (server config)"
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern) }
} else if grant.Allow.IsWrite() { if grant.Permission.IsReadWrite() {
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern) fmt.Fprintf(c.App.Writer, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else if grant.Permission.IsRead() {
fmt.Fprintf(c.App.Writer, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else if grant.Permission.IsWrite() {
fmt.Fprintf(c.App.Writer, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else { } else {
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern) fmt.Fprintf(c.App.Writer, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} }
} }
} else { } else {
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n") fmt.Fprintf(c.App.Writer, "- no topic-specific permissions\n")
} }
if u.Name == user.Everyone { if u.Name == user.Everyone {
access := manager.DefaultAccess() access := manager.DefaultAccess()
if access.IsReadWrite() { if access.IsReadWrite() {
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)") fmt.Fprintln(c.App.Writer, "- read-write access to all (other) topics (server config)")
} else if access.IsRead() { } else if access.IsRead() {
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)") fmt.Fprintln(c.App.Writer, "- read-only access to all (other) topics (server config)")
} else if access.IsWrite() { } else if access.IsWrite() {
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)") fmt.Fprintln(c.App.Writer, "- write-only access to all (other) topics (server config)")
} else { } else {
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)") fmt.Fprintln(c.App.Writer, "- no access to any (other) topics (server config)")
} }
} }
} }

View File

@@ -13,9 +13,9 @@ func TestCLI_Access_Show(t *testing.T) {
s, conf, port := newTestServerWithAuth(t) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
app, _, _, stderr := newTestApp() app, _, stdout, _ := newTestApp()
require.Nil(t, runAccessCommand(app, conf)) require.Nil(t, runAccessCommand(app, conf))
require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") require.Contains(t, stdout.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
} }
func TestCLI_Access_Grant_And_Publish(t *testing.T) { func TestCLI_Access_Grant_And_Publish(t *testing.T) {
@@ -30,7 +30,7 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) {
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read")) require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read")) require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
app, _, _, stderr := newTestApp() app, _, stdout, _ := newTestApp()
require.Nil(t, runAccessCommand(app, conf)) require.Nil(t, runAccessCommand(app, conf))
expected := `user phil (role: admin, tier: none) expected := `user phil (role: admin, tier: none)
- read-write access to all topics (admin role) - read-write access to all topics (admin role)
@@ -41,7 +41,7 @@ user * (role: anonymous, tier: none)
- read-only access to topic announcements - read-only access to topic announcements
- no access to any (other) topics (server config) - no access to any (other) topics (server config)
` `
require.Equal(t, expected, stderr.String()) require.Equal(t, expected, stdout.String())
// See if access permissions match // See if access permissions match
app, _, _, _ = newTestApp() app, _, _, _ = newTestApp()

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"
@@ -48,6 +48,9 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
@@ -154,6 +157,9 @@ func execServe(c *cli.Context) error {
authFile := c.String("auth-file") authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries") authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access") authDefaultAccess := c.String("auth-default-access")
authUsersRaw := c.StringSlice("auth-users")
authAccessRaw := c.StringSlice("auth-access")
authTokensRaw := c.StringSlice("auth-tokens")
attachmentCacheDir := c.String("attachment-cache-dir") attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
@@ -273,6 +279,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 {
@@ -314,6 +322,8 @@ func execServe(c *cli.Context) error {
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, 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 !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 == "") {
@@ -323,6 +333,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 == "" {
@@ -344,11 +356,23 @@ func execServe(c *cli.Context) error {
webRoot = "/" + webRoot webRoot = "/" + webRoot
} }
// Default auth permissions // Convert default auth permission, read provisioned users
authDefault, err := user.ParsePermission(authDefaultAccess) authDefault, err := user.ParsePermission(authDefaultAccess)
if err != nil { if err != nil {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
} }
authUsers, err := parseUsers(authUsersRaw)
if err != nil {
return err
}
authAccess, err := parseAccess(authUsers, authAccessRaw)
if err != nil {
return err
}
authTokens, err := parseTokens(authUsers, authTokensRaw)
if err != nil {
return err
}
// Special case: Unset default // Special case: Unset default
if listenHTTP == "-" { if listenHTTP == "-" {
@@ -378,8 +402,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
@@ -404,6 +427,9 @@ func execServe(c *cli.Context) error {
conf.AuthFile = authFile conf.AuthFile = authFile
conf.AuthStartupQueries = authStartupQueries conf.AuthStartupQueries = authStartupQueries
conf.AuthDefault = authDefault conf.AuthDefault = authDefault
conf.AuthUsers = authUsers
conf.AuthAccess = authAccess
conf.AuthTokens = authTokens
conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
@@ -517,6 +543,112 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
return return
} }
func parseUsers(usersRaw []string) ([]*user.User, error) {
users := make([]*user.User, 0)
for _, userLine := range usersRaw {
parts := strings.Split(userLine, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid auth-users: %s, expected format: 'name:hash:role'", userLine)
}
username := strings.TrimSpace(parts[0])
passwordHash := strings.TrimSpace(parts[1])
role := user.Role(strings.TrimSpace(parts[2]))
if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine)
} else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
} 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)
}
users = append(users, &user.User{
Name: username,
Hash: passwordHash,
Role: role,
Provisioned: true,
})
}
return users, nil
}
func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) {
access := make(map[string][]*user.Grant)
for _, accessLine := range accessRaw {
parts := strings.Split(accessLine, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine)
}
username := strings.TrimSpace(parts[0])
if username == userEveryone {
username = user.Everyone
}
u, exists := util.Find(users, func(u *user.User) bool {
return u.Name == username
})
if username != user.Everyone {
if !exists {
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username)
} else if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username)
} else if u.Role != user.RoleUser {
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username)
}
}
topic := strings.TrimSpace(parts[1])
if !user.AllowedTopicPattern(topic) {
return nil, fmt.Errorf("invalid auth-access: %s, topic pattern %s invalid", accessLine, topic)
}
permission, err := user.ParsePermission(strings.TrimSpace(parts[2]))
if err != nil {
return nil, fmt.Errorf("invalid auth-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error())
}
if _, exists := access[username]; !exists {
access[username] = make([]*user.Grant, 0)
}
access[username] = append(access[username], &user.Grant{
TopicPattern: topic,
Permission: permission,
Provisioned: true,
})
}
return access, nil
}
func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Token, error) {
tokens := make(map[string][]*user.Token)
for _, tokenLine := range tokensRaw {
parts := strings.Split(tokenLine, ":")
if len(parts) < 2 || len(parts) > 3 {
return nil, fmt.Errorf("invalid auth-tokens: %s, expected format: 'user:token[:label]'", tokenLine)
}
username := strings.TrimSpace(parts[0])
_, exists := util.Find(users, func(u *user.User) bool {
return u.Name == username
})
if !exists {
return nil, fmt.Errorf("invalid auth-tokens: %s, user %s is not provisioned", tokenLine, username)
} else if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-tokens: %s, username %s invalid", tokenLine, username)
}
token := strings.TrimSpace(parts[1])
if !user.ValidToken(token) {
return nil, fmt.Errorf("invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token", tokenLine, token)
}
var label string
if len(parts) > 2 {
label = parts[2]
}
if _, exists := tokens[username]; !exists {
tokens[username] = make([]*user.Token, 0)
}
tokens[username] = append(tokens[username], &user.Token{
Value: token,
Label: label,
Provisioned: true,
})
}
return tokens, nil
}
func reloadLogLevel(inputSource altsrc.InputSourceContext) error { func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
newLevelStr, err := inputSource.String("log-level") newLevelStr, err := inputSource.String("log-level")
if err != nil { if err != nil {

View File

@@ -14,9 +14,461 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/client"
"heckel.io/ntfy/v2/test" "heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
func TestParseUsers_Success(t *testing.T) {
tests := []struct {
name string
input []string
expected []*user.User
}{
{
name: "single user",
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
expected: []*user.User{
{
Name: "alice",
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
Role: user.RoleUser,
Provisioned: true,
},
},
},
{
name: "multiple users with different roles",
input: []string{
"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
"bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
},
expected: []*user.User{
{
Name: "alice",
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
Role: user.RoleUser,
Provisioned: true,
},
{
Name: "bob",
Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
Role: user.RoleAdmin,
Provisioned: true,
},
},
},
{
name: "empty input",
input: []string{},
expected: []*user.User{},
},
{
name: "user with special characters in name",
input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"},
expected: []*user.User{
{
Name: "alice.test+123@example.com",
Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
Role: user.RoleUser,
Provisioned: true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseUsers(tt.input)
require.NoError(t, err)
require.Len(t, result, len(tt.expected))
for i, expectedUser := range tt.expected {
assert.Equal(t, expectedUser.Name, result[i].Name)
assert.Equal(t, expectedUser.Hash, result[i].Hash)
assert.Equal(t, expectedUser.Role, result[i].Role)
assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned)
}
})
}
}
func TestParseUsers_Errors(t *testing.T) {
tests := []struct {
name string
input []string
error string
}{
{
name: "invalid format - too few parts",
input: []string{"alice:hash"},
error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'",
},
{
name: "invalid format - too many parts",
input: []string{"alice:hash:role:extra"},
error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'",
},
{
name: "invalid username",
input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
},
{
name: "invalid password hash - wrong prefix",
input: []string{"alice:plaintext:user"},
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",
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
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",
input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseUsers(tt.input)
require.Error(t, err)
require.Nil(t, result)
assert.Contains(t, err.Error(), tt.error)
})
}
}
func TestParseAccess_Success(t *testing.T) {
users := []*user.User{
{Name: "alice", Role: user.RoleUser},
{Name: "bob", Role: user.RoleUser},
}
tests := []struct {
name string
users []*user.User
input []string
expected map[string][]*user.Grant
}{
{
name: "single access entry",
users: users,
input: []string{"alice:mytopic:read-write"},
expected: map[string][]*user.Grant{
"alice": {
{
TopicPattern: "mytopic",
Permission: user.PermissionReadWrite,
Provisioned: true,
},
},
},
},
{
name: "multiple access entries for same user",
users: users,
input: []string{
"alice:topic1:read-only",
"alice:topic2:write-only",
},
expected: map[string][]*user.Grant{
"alice": {
{
TopicPattern: "topic1",
Permission: user.PermissionRead,
Provisioned: true,
},
{
TopicPattern: "topic2",
Permission: user.PermissionWrite,
Provisioned: true,
},
},
},
},
{
name: "access for everyone",
users: users,
input: []string{"everyone:publictopic:read-only"},
expected: map[string][]*user.Grant{
user.Everyone: {
{
TopicPattern: "publictopic",
Permission: user.PermissionRead,
Provisioned: true,
},
},
},
},
{
name: "wildcard topic pattern",
users: users,
input: []string{"alice:topic*:read-write"},
expected: map[string][]*user.Grant{
"alice": {
{
TopicPattern: "topic*",
Permission: user.PermissionReadWrite,
Provisioned: true,
},
},
},
},
{
name: "empty input",
users: users,
input: []string{},
expected: map[string][]*user.Grant{},
},
{
name: "deny-all permission",
users: users,
input: []string{"alice:secretopic:deny-all"},
expected: map[string][]*user.Grant{
"alice": {
{
TopicPattern: "secretopic",
Permission: user.PermissionDenyAll,
Provisioned: true,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseAccess(tt.users, tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseAccess_Errors(t *testing.T) {
users := []*user.User{
{Name: "alice", Role: user.RoleUser},
{Name: "admin", Role: user.RoleAdmin},
}
tests := []struct {
name string
users []*user.User
input []string
error string
}{
{
name: "invalid format - too few parts",
users: users,
input: []string{"alice:topic"},
error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'",
},
{
name: "invalid format - too many parts",
users: users,
input: []string{"alice:topic:read:extra"},
error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'",
},
{
name: "user not provisioned",
users: users,
input: []string{"charlie:topic:read"},
error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned",
},
{
name: "admin user cannot have ACL entries",
users: users,
input: []string{"admin:topic:read"},
error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries",
},
{
name: "invalid topic pattern",
users: users,
input: []string{"alice:topic-with-invalid-chars!:read"},
error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid",
},
{
name: "invalid permission",
users: users,
input: []string{"alice:topic:invalid-permission"},
error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseAccess(tt.users, tt.input)
require.Error(t, err)
require.Nil(t, result)
assert.Contains(t, err.Error(), tt.error)
})
}
}
func TestParseTokens_Success(t *testing.T) {
users := []*user.User{
{Name: "alice"},
{Name: "bob"},
}
tests := []struct {
name string
users []*user.User
input []string
expected map[string][]*user.Token
}{
{
name: "single token without label",
users: users,
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"},
expected: map[string][]*user.Token{
"alice": {
{
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
Label: "",
Provisioned: true,
},
},
},
},
{
name: "single token with label",
users: users,
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"},
expected: map[string][]*user.Token{
"alice": {
{
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
Label: "My Phone",
Provisioned: true,
},
},
},
},
{
name: "multiple tokens for same user",
users: users,
input: []string{
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
"alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop",
},
expected: map[string][]*user.Token{
"alice": {
{
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
Label: "Phone",
Provisioned: true,
},
{
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
Label: "Laptop",
Provisioned: true,
},
},
},
},
{
name: "tokens for multiple users",
users: users,
input: []string{
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
"bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet",
},
expected: map[string][]*user.Token{
"alice": {
{
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
Label: "Phone",
Provisioned: true,
},
},
"bob": {
{
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
Label: "Tablet",
Provisioned: true,
},
},
},
},
{
name: "empty input",
users: users,
input: []string{},
expected: map[string][]*user.Token{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseTokens(tt.users, tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseTokens_Errors(t *testing.T) {
users := []*user.User{
{Name: "alice"},
}
tests := []struct {
name string
users []*user.User
input []string
error string
}{
{
name: "invalid format - too few parts",
users: users,
input: []string{"alice"},
error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'",
},
{
name: "invalid format - too many parts",
users: users,
input: []string{"alice:token:label:extra:parts"},
error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'",
},
{
name: "user not provisioned",
users: users,
input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"},
error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned",
},
{
name: "invalid token format",
users: users,
input: []string{"alice:invalid-token"},
error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token",
},
{
name: "token too short",
users: users,
input: []string{"alice:tk_short"},
error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token",
},
{
name: "token without prefix",
users: users,
input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"},
error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseTokens(tt.users, tt.input)
require.Error(t, err)
require.Nil(t, result)
assert.Contains(t, err.Error(), tt.error)
})
}
}
func TestCLI_Serve_Unix_Curl(t *testing.T) { func TestCLI_Serve_Unix_Curl(t *testing.T) {
sockFile := filepath.Join(t.TempDir(), "ntfy.sock") sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system

View File

@@ -182,7 +182,7 @@ func execTierAdd(c *cli.Context) error {
} }
if tier, _ := manager.Tier(code); tier != nil { if tier, _ := manager.Tier(code); tier != nil {
if c.Bool("ignore-exists") { if c.Bool("ignore-exists") {
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code) fmt.Fprintf(c.App.Writer, "tier %s already exists (exited successfully)\n", code)
return nil return nil
} }
return fmt.Errorf("tier %s already exists", code) return fmt.Errorf("tier %s already exists", code)
@@ -234,7 +234,7 @@ func execTierAdd(c *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n") fmt.Fprintf(c.App.Writer, "tier added\n\n")
printTier(c, tier) printTier(c, tier)
return nil return nil
} }
@@ -315,7 +315,7 @@ func execTierChange(c *cli.Context) error {
if err := manager.UpdateTier(tier); err != nil { if err := manager.UpdateTier(tier); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n") fmt.Fprintf(c.App.Writer, "tier updated\n\n")
printTier(c, tier) printTier(c, tier)
return nil return nil
} }
@@ -335,7 +335,7 @@ func execTierDel(c *cli.Context) error {
if err := manager.RemoveTier(code); err != nil { if err := manager.RemoveTier(code); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code) fmt.Fprintf(c.App.Writer, "tier %s removed\n", code)
return nil return nil
} }
@@ -359,16 +359,16 @@ func printTier(c *cli.Context, tier *user.Tier) {
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" { if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID) prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
} }
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID) fmt.Fprintf(c.App.Writer, "tier %s (id: %s)\n", tier.Code, tier.ID)
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name) fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name)
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) fmt.Fprintf(c.App.Writer, "- Message limit: %d\n", tier.MessageLimit)
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) fmt.Fprintf(c.App.Writer, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) fmt.Fprintf(c.App.Writer, "- Email limit: %d\n", tier.EmailLimit)
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit)
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) fmt.Fprintf(c.App.Writer, "- Reservation limit: %d\n", tier.ReservationLimit)
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit)) fmt.Fprintf(c.App.Writer, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit)) fmt.Fprintf(c.App.Writer, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) fmt.Fprintf(c.App.Writer, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit)) fmt.Fprintf(c.App.Writer, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices) fmt.Fprintf(c.App.Writer, "- Stripe prices (monthly/yearly): %s\n", prices)
} }

View File

@@ -12,21 +12,21 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
s, conf, port := newTestServerWithAuth(t) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
app, _, _, stderr := newTestApp() app, _, stdout, _ := newTestApp()
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro")) require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_") require.Contains(t, stdout.String(), "tier added\n\ntier pro (id: ti_")
err := runTierCommand(app, conf, "add", "pro") err := runTierCommand(app, conf, "add", "pro")
require.NotNil(t, err) require.NotNil(t, err)
require.Equal(t, "tier pro already exists", err.Error()) require.Equal(t, "tier pro already exists", err.Error())
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runTierCommand(app, conf, "list")) require.Nil(t, runTierCommand(app, conf, "list"))
require.Contains(t, stderr.String(), "tier pro (id: ti_") require.Contains(t, stdout.String(), "tier pro (id: ti_")
require.Contains(t, stderr.String(), "- Name: Pro") require.Contains(t, stdout.String(), "- Name: Pro")
require.Contains(t, stderr.String(), "- Message limit: 1234") require.Contains(t, stdout.String(), "- Message limit: 1234")
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runTierCommand(app, conf, "change", require.Nil(t, runTierCommand(app, conf, "change",
"--message-limit=999", "--message-limit=999",
"--message-expiry-duration=2d", "--message-expiry-duration=2d",
@@ -40,18 +40,18 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
"--stripe-yearly-price-id=price_992", "--stripe-yearly-price-id=price_992",
"pro", "pro",
)) ))
require.Contains(t, stderr.String(), "- Message limit: 999") require.Contains(t, stdout.String(), "- Message limit: 999")
require.Contains(t, stderr.String(), "- Message expiry duration: 48h") require.Contains(t, stdout.String(), "- Message expiry duration: 48h")
require.Contains(t, stderr.String(), "- Email limit: 91") require.Contains(t, stdout.String(), "- Email limit: 91")
require.Contains(t, stderr.String(), "- Reservation limit: 98") require.Contains(t, stdout.String(), "- Reservation limit: 98")
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB") require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB")
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h") require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h")
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB") require.Contains(t, stdout.String(), "- Attachment total size limit: 10.0 GB")
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992") require.Contains(t, stdout.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runTierCommand(app, conf, "remove", "pro")) require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
require.Contains(t, stderr.String(), "tier pro removed") require.Contains(t, stdout.String(), "tier pro removed")
} }
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error { func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {

View File

@@ -72,6 +72,15 @@ Example:
This is a server-only command. It directly reads from user.db as defined in the server config This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.`, file server.yml. The command only works if 'auth-file' is properly defined.`,
}, },
{
Name: "generate",
Usage: "Generates a random token",
Action: execTokenGenerate,
Description: `Randomly generate a token to be used in provisioned tokens.
This command only generates the token value, but does not persist it anywhere.
The output can be used in the 'auth-tokens' config option.`,
},
}, },
Description: `Manage access tokens for individual users. Description: `Manage access tokens for individual users.
@@ -112,19 +121,19 @@ func execTokenAdd(c *cli.Context) error {
return err return err
} }
u, err := manager.User(username) u, err := manager.User(username)
if err == user.ErrUserNotFound { if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} else if err != nil { } else if err != nil {
return err return err
} }
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified()) token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified(), false)
if err != nil { if err != nil {
return err return err
} }
if expires.Unix() == 0 { if expires.Unix() == 0 {
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name) fmt.Fprintf(c.App.Writer, "token %s created for user %s, never expires\n", token.Value, u.Name)
} else { } else {
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) fmt.Fprintf(c.App.Writer, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
} }
return nil return nil
} }
@@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error {
return err return err
} }
u, err := manager.User(username) u, err := manager.User(username)
if err == user.ErrUserNotFound { if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} else if err != nil { } else if err != nil {
return err return err
@@ -149,7 +158,7 @@ func execTokenDel(c *cli.Context) error {
if err := manager.RemoveToken(u.ID, token); err != nil { if err := manager.RemoveToken(u.ID, token); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username) fmt.Fprintf(c.App.Writer, "token %s for user %s removed\n", token, username)
return nil return nil
} }
@@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error {
var users []*user.User var users []*user.User
if username != "" { if username != "" {
u, err := manager.User(username) u, err := manager.User(username)
if err == user.ErrUserNotFound { if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} else if err != nil { } else if err != nil {
return err return err
@@ -183,15 +192,15 @@ func execTokenList(c *cli.Context) error {
if err != nil { if err != nil {
return err return err
} else if len(tokens) == 0 && username != "" { } else if len(tokens) == 0 && username != "" {
fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username) fmt.Fprintf(c.App.Writer, "user %s has no access tokens\n", username)
return nil return nil
} else if len(tokens) == 0 { } else if len(tokens) == 0 {
continue continue
} }
usersWithTokens++ usersWithTokens++
fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name) fmt.Fprintf(c.App.Writer, "user %s\n", u.Name)
for _, t := range tokens { for _, t := range tokens {
var label, expires string var label, expires, provisioned string
if t.Label != "" { if t.Label != "" {
label = fmt.Sprintf(" (%s)", t.Label) label = fmt.Sprintf(" (%s)", t.Label)
} }
@@ -200,11 +209,19 @@ func execTokenList(c *cli.Context) error {
} else { } else {
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822)) expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
} }
fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822)) if t.Provisioned {
provisioned = " (server config)"
}
fmt.Fprintf(c.App.Writer, "- %s%s, %s, accessed from %s at %s%s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822), provisioned)
} }
} }
if usersWithTokens == 0 { if usersWithTokens == 0 {
fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n") fmt.Fprintf(c.App.Writer, "no users with tokens\n")
} }
return nil return nil
} }
func execTokenGenerate(c *cli.Context) error {
fmt.Fprintln(c.App.Writer, user.GenerateToken())
return nil
}

View File

@@ -14,28 +14,28 @@ func TestCLI_Token_AddListRemove(t *testing.T) {
s, conf, port := newTestServerWithAuth(t) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
app, stdin, _, stderr := newTestApp() app, stdin, stdout, _ := newTestApp()
stdin.WriteString("mypass\nmypass") stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user") require.Contains(t, stdout.String(), "user phil added with role user")
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runTokenCommand(app, conf, "add", "phil")) require.Nil(t, runTokenCommand(app, conf, "add", "phil"))
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String()) require.Regexp(t, `token tk_.+ created for user phil, never expires`, stdout.String())
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runTokenCommand(app, conf, "list", "phil")) require.Nil(t, runTokenCommand(app, conf, "list", "phil"))
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String()) require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stdout.String())
re := regexp.MustCompile(`tk_\w+`) re := regexp.MustCompile(`tk_\w+`)
token := re.FindString(stderr.String()) token := re.FindString(stdout.String())
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token)) require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token))
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String()) require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stdout.String())
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runTokenCommand(app, conf, "list")) require.Nil(t, runTokenCommand(app, conf, "list"))
require.Equal(t, "no users with tokens\n", stderr.String()) require.Equal(t, "no users with tokens\n", stdout.String())
} }
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error { func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {

View File

@@ -95,7 +95,6 @@ Example:
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
directly the bcrypt hash. This is useful if you are updating users via scripts. directly the bcrypt hash. This is useful if you are updating users via scripts.
`, `,
}, },
{ {
@@ -134,6 +133,22 @@ as messages per day, attachment file sizes, etc.
Example: Example:
ntfy user change-tier phil pro # Change tier to "pro" for user "phil" ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
ntfy user change-tier phil - # Remove tier from user "phil" entirely ntfy user change-tier phil - # Remove tier from user "phil" entirely
`,
},
{
Name: "hash",
Usage: "Create password hash for a predefined user",
UsageText: "ntfy user hash",
Action: execUserHash,
Description: `Asks for a password and creates a bcrypt password hash.
This command is useful to create a password hash for a user, which can then be used
for predefined users in the server config file, in auth-users.
Example:
$ ntfy user hash
(asks for password and confirmation)
$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C
`, `,
}, },
{ {
@@ -196,7 +211,7 @@ func execUserAdd(c *cli.Context) error {
} }
if user, _ := manager.User(username); user != nil { if user, _ := manager.User(username); user != nil {
if c.Bool("ignore-exists") { if c.Bool("ignore-exists") {
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username) fmt.Fprintf(c.App.Writer, "user %s already exists (exited successfully)\n", username)
return nil return nil
} }
return fmt.Errorf("user %s already exists", username) return fmt.Errorf("user %s already exists", username)
@@ -211,7 +226,7 @@ func execUserAdd(c *cli.Context) error {
if err := manager.AddUser(username, password, role, hashed); err != nil { if err := manager.AddUser(username, password, role, hashed); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role) fmt.Fprintf(c.App.Writer, "user %s added with role %s\n", username, role)
return nil return nil
} }
@@ -226,13 +241,13 @@ func execUserDel(c *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
if _, err := manager.User(username); err == user.ErrUserNotFound { if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} }
if err := manager.RemoveUser(username); err != nil { if err := manager.RemoveUser(username); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username) fmt.Fprintf(c.App.Writer, "user %s removed\n", username)
return nil return nil
} }
@@ -252,7 +267,7 @@ func execUserChangePass(c *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
if _, err := manager.User(username); err == user.ErrUserNotFound { if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} }
if password == "" { if password == "" {
@@ -264,7 +279,7 @@ func execUserChangePass(c *cli.Context) error {
if err := manager.ChangePassword(username, password, hashed); err != nil { if err := manager.ChangePassword(username, password, hashed); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username) fmt.Fprintf(c.App.Writer, "changed password for user %s\n", username)
return nil return nil
} }
@@ -280,13 +295,26 @@ func execUserChangeRole(c *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
if _, err := manager.User(username); err == user.ErrUserNotFound { if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} }
if err := manager.ChangeRole(username, role); err != nil { if err := manager.ChangeRole(username, role); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role) fmt.Fprintf(c.App.Writer, "changed role for user %s to %s\n", username, role)
return nil
}
func execUserHash(c *cli.Context) error {
password, err := readPasswordAndConfirm(c)
if err != nil {
return err
}
hash, err := user.HashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
fmt.Fprintln(c.App.Writer, hash)
return nil return nil
} }
@@ -304,19 +332,19 @@ func execUserChangeTier(c *cli.Context) error {
if err != nil { if err != nil {
return err return err
} }
if _, err := manager.User(username); err == user.ErrUserNotFound { if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} }
if tier == tierReset { if tier == tierReset {
if err := manager.ResetTier(username); err != nil { if err := manager.ResetTier(username); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username) fmt.Fprintf(c.App.Writer, "removed tier from user %s\n", username)
} else { } else {
if err := manager.ChangeTier(username, tier); err != nil { if err := manager.ChangeTier(username, tier); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier) fmt.Fprintf(c.App.Writer, "changed tier for user %s to %s\n", username, tier)
} }
return nil return nil
} }
@@ -346,7 +374,15 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
if err != nil { if err != nil {
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
} }
return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval) authConfig := &user.Config{
Filename: authFile,
StartupQueries: authStartupQueries,
DefaultAccess: authDefault,
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
}
return user.NewManager(authConfig)
} }
func readPasswordAndConfirm(c *cli.Context) (string, error) { func readPasswordAndConfirm(c *cli.Context) (string, error) {

View File

@@ -15,20 +15,20 @@ func TestCLI_User_Add(t *testing.T) {
s, conf, port := newTestServerWithAuth(t) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
app, stdin, _, stderr := newTestApp() app, stdin, stdout, _ := newTestApp()
stdin.WriteString("mypass\nmypass") stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user") require.Contains(t, stdout.String(), "user phil added with role user")
} }
func TestCLI_User_Add_Exists(t *testing.T) { func TestCLI_User_Add_Exists(t *testing.T) {
s, conf, port := newTestServerWithAuth(t) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
app, stdin, _, stderr := newTestApp() app, stdin, stdout, _ := newTestApp()
stdin.WriteString("mypass\nmypass") stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user") require.Contains(t, stdout.String(), "user phil added with role user")
app, stdin, _, _ = newTestApp() app, stdin, _, _ = newTestApp()
stdin.WriteString("mypass\nmypass") stdin.WriteString("mypass\nmypass")
@@ -41,10 +41,10 @@ func TestCLI_User_Add_Admin(t *testing.T) {
s, conf, port := newTestServerWithAuth(t) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
app, stdin, _, stderr := newTestApp() app, stdin, stdout, _ := newTestApp()
stdin.WriteString("mypass\nmypass") stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil")) require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
require.Contains(t, stderr.String(), "user phil added with role admin") require.Contains(t, stdout.String(), "user phil added with role admin")
} }
func TestCLI_User_Add_Password_Mismatch(t *testing.T) { func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
@@ -60,19 +60,27 @@ 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
app, stdin, _, stderr := newTestApp() app, stdin, stdout, _ := newTestApp()
stdin.WriteString("mypass\nmypass") stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user") require.Contains(t, stdout.String(), "user phil added with role user")
// Change pass // Change pass
app, stdin, _, stderr = newTestApp() app, stdin, stdout, _ = newTestApp()
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, stderr.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) {
@@ -80,15 +88,15 @@ func TestCLI_User_ChangeRole(t *testing.T) {
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
// Add user // Add user
app, stdin, _, stderr := newTestApp() app, stdin, stdout, _ := newTestApp()
stdin.WriteString("mypass\nmypass") stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user") require.Contains(t, stdout.String(), "user phil added with role user")
// Change role // Change role
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin")) require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
require.Contains(t, stderr.String(), "changed role for user phil to admin") require.Contains(t, stdout.String(), "changed role for user phil to admin")
} }
func TestCLI_User_Delete(t *testing.T) { func TestCLI_User_Delete(t *testing.T) {
@@ -96,15 +104,15 @@ func TestCLI_User_Delete(t *testing.T) {
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
// Add user // Add user
app, stdin, _, stderr := newTestApp() app, stdin, stdout, _ := newTestApp()
stdin.WriteString("mypass\nmypass") stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil")) require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user") require.Contains(t, stdout.String(), "user phil added with role user")
// Delete user // Delete user
app, _, _, stderr = newTestApp() app, _, stdout, _ = newTestApp()
require.Nil(t, runUserCommand(app, conf, "del", "phil")) require.Nil(t, runUserCommand(app, conf, "del", "phil"))
require.Contains(t, stderr.String(), "user phil removed") require.Contains(t, stdout.String(), "user phil removed")
// Delete user again (does not exist) // Delete user again (does not exist)
app, _, _, _ = newTestApp() app, _, _, _ = newTestApp()

View File

@@ -1,4 +1,4 @@
//go:build !noserver //go:build !noserver && !nowebpush
package cmd package cmd
@@ -53,9 +53,9 @@ web-push-private-key: %s
if err != nil { if err != nil {
return err return err
} }
_, err = fmt.Fprintf(c.App.ErrWriter, "Web Push keys written to %s.\n", outputFile) _, err = fmt.Fprintf(c.App.Writer, "Web Push keys written to %s.\n", outputFile)
} else { } else {
_, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file: _, err = fmt.Fprintf(c.App.Writer, `Web Push keys generated. Add the following lines to your config file:
web-push-public-key: %s web-push-public-key: %s
web-push-private-key: %s web-push-private-key: %s

View File

@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -9,16 +10,18 @@ import (
) )
func TestCLI_WebPush_GenerateKeys(t *testing.T) { func TestCLI_WebPush_GenerateKeys(t *testing.T) {
app, _, _, stderr := newTestApp() app, _, stdout, _ := newTestApp()
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys")) require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
require.Contains(t, stderr.String(), "Web Push keys generated.") require.Contains(t, stdout.String(), "Web Push keys generated.")
} }
func TestCLI_WebPush_WriteKeysToFile(t *testing.T) { func TestCLI_WebPush_WriteKeysToFile(t *testing.T) {
app, _, _, stderr := newTestApp() tempDir := t.TempDir()
t.Chdir(tempDir)
app, _, stdout, _ := newTestApp()
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml")) require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml"))
require.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml") require.Contains(t, stdout.String(), "Web Push keys written to key-file.yaml")
require.FileExists(t, "key-file.yaml") require.FileExists(t, filepath.Join(tempDir, "key-file.yaml"))
} }
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error { func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {

View File

@@ -88,6 +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' # 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
@@ -188,19 +189,31 @@ ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based
(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). (`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).
Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access. Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.
To set up auth, simply **configure the following two options**: To set up auth, **configure the following options**:
* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested * `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used) location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used)
* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be
set to `read-write` (default), `read-only`, `write-only` or `deny-all`. set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance,
you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)).
Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command Once configured, you can use
lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these
commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user - the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles)
accessing them has the right permissions. - the `ntfy access` command and the `auth-access` option to [modify the access control list](#access-control-list-acl)
and topic patterns, and
- the `ntfy token` command and the `auth-tokens` config option to [manage access tokens](#access-tokens) for users.
These commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server,
and only if the user accessing them has the right permissions.
### Users and roles ### Users and roles
Users can be added to the ntfy user database in two different ways
* [Using the CLI](#users-via-the-cli): Using the `ntfy user` command, you can manually add/update/remove users.
* [In the config](#users-via-the-config): You can provision users in the `server.yml` file via `auth-users` key.
#### Users via the CLI
The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change
passwords or roles (`user` or `admin`). In practice, you'll often just create one admin passwords or roles (`user` or `admin`). In practice, you'll often just create one admin
user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)). user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)).
@@ -221,12 +234,54 @@ ntfy user del phil # Delete user phil
ntfy user change-pass phil # Change password for user phil ntfy user change-pass phil # Change password for user phil
ntfy user change-role phil admin # Make user phil an admin ntfy user change-role phil admin # Make user phil an admin
ntfy user change-tier phil pro # Change phil's tier to "pro" ntfy user change-tier phil pro # Change phil's tier to "pro"
ntfy user hash # Generate password hash, use with auth-users config option
``` ```
#### Users via the config
As an alternative to manually creating users via the `ntfy user` CLI command, you can provision users declaratively in
the `server.yml` file by adding them to the `auth-users` array. This is useful for general admins, or if you'd like to
deploy your ntfy server via Docker/Ansible without manually editing the database.
The `auth-users` option is a list of users that are automatically created/updated when the server starts. Users
previously defined in the config but later removed will be deleted. Each entry is defined in the format `<username>:<password-hash>:<role>`.
Here's an example with two users: `phil` is an admin, `ben` is a regular user.
=== "Declarative users in /etc/ntfy/server.yml"
``` yaml
auth-file: "/var/lib/ntfy/user.db"
auth-users:
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
- "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
```
=== "Declarative users via env variables"
```
# Comma-separated list, use single quotes to avoid issues with the bcrypt hash
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
```
The password hash can be created using `ntfy user hash` or an [online bcrypt generator](https://bcrypt-generator.com/) (though
note that you're putting your password in an untrusted website).
!!! important
Users added declaratively via the config file are marked in the database as "provisioned users". Removing users
from the config file will **delete them from the database** the next time ntfy is restarted.
Also, users that were originally manually created will be "upgraded" to be provisioned users if they are added to
the config. Adding a user manually, then adding it to the config, and then removing it from the config will hence
lead to the **deletion of that user**.
### Access control list (ACL) ### Access control list (ACL)
The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**. The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.
Each entry represents the access permissions for a user to a specific topic or topic pattern. Each entry represents the access permissions for a user to a specific topic or topic pattern. Entries can be created in
two different ways:
* [Using the CLI](#acl-entries-via-the-cli): Using the `ntfy access` command, you can manually edit the access control list.
* [In the config](#acl-entries-via-the-config): You can provision ACL entries in the `server.yml` file via `auth-access` key.
#### ACL entries via the CLI
The ACL can be displayed or modified with the `ntfy access` command: The ACL can be displayed or modified with the `ntfy access` command:
``` ```
@@ -282,6 +337,51 @@ User `ben` has three topic-specific entries. He can read, but not write to topic
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
#### ACL entries via the config
As an alternative to manually creating ACL entries via the `ntfy access` CLI command, you can provision access control
entries declaratively in the `server.yml` file by adding them to the `auth-access` array, similar to the `auth-users`
option (see [users via the config](#users-via-the-config).
The `auth-access` option is a list of access control entries that are automatically created/updated when the server starts.
When entries are removed, they are deleted from the database. Each entry is defined in the format `<username>:<topic-pattern>:<access>`.
The `<username>` can be any existing, provisioned user as defined in the `auth-users` section (see [users via the config](#users-via-the-config)),
or `everyone`/`*` for anonymous access. The `<topic-pattern>` can be a specific topic name or a pattern with wildcards (`*`). The
`<access>` can be one of the following:
* `read-write` or `rw`: Allows both publishing to and subscribing to the topic
* `read-only`, `read`, or `ro`: Allows only subscribing to the topic
* `write-only`, `write`, or `wo`: Allows only publishing to the topic
* `deny-all`, `deny`, or `none`: Denies all access to the topic
Here's an example with several ACL entries:
=== "Declarative ACL entries in /etc/ntfy/server.yml"
``` yaml
auth-file: "/var/lib/ntfy/user.db"
auth-users:
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
- "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
auth-access:
- "phil:mytopic:rw"
- "ben:alerts-*:rw"
- "ben:system-logs:ro"
- "*:announcements:ro" # or: "everyone:announcements,ro"
```
=== "Declarative ACL entries via env variables"
```
# Comma-separated list
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
NTFY_AUTH_ACCESS='phil:mytopic:rw,ben:alerts-*:rw,ben:system-logs:ro,*:announcements:ro'
```
In this example, the `auth-users` section defines two users, `phil` and `ben`. The `auth-access` section defines
access control entries for these users. `phil` has read-write access to the topic `mytopic`, while `ben` has read-write
access to all topics starting with `alerts-` and read-only access to the topic `system-logs`. The last entry allows
anonymous users (i.e. clients that do not authenticate) to read the `announcements` topic.
### Access tokens ### Access tokens
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
@@ -292,6 +392,12 @@ want to use a dedicated token to publish from your backup host, and one from you
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap, and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
but not yet implemented. but not yet implemented.
You can create access tokens in two different ways:
* [Using the CLI](#tokens-via-the-cli): Using the `ntfy token` command, you can manually add/update/remove tokens.
* [In the config](#tokens-via-the-config): You can provision access tokens in the `server.yml` file via `auth-tokens` key.
#### Tokens via the CLI
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
automatically (or never expire). Each user can have up to 60 tokens (hardcoded). automatically (or never expire). Each user can have up to 60 tokens (hardcoded).
@@ -302,6 +408,7 @@ ntfy token list phil # Shows list of tokens for user phil
ntfy token add phil # Create token for user phil which never expires ntfy token add phil # Create token for user phil which never expires
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
ntfy token remove phil tk_th2sxr... # Delete token ntfy token remove phil tk_th2sxr... # Delete token
ntfy token generate # Generate random token, can be used in auth-tokens config option
``` ```
**Creating an access token:** **Creating an access token:**
@@ -309,32 +416,89 @@ ntfy token remove phil tk_th2sxr... # Delete token
$ ntfy token add --expires=30d --label="backups" phil $ ntfy token add --expires=30d --label="backups" phil
$ ntfy token list $ ntfy token list
user phil user phil
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST - tk_7eevizlsiwf9yi4uxsrs83r4352o0 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
``` ```
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens). subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
### Example: Private instance #### Tokens via the config
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`: Access tokens can be pre-provisioned in the `server.yml` configuration file using the `auth-tokens` config option.
This is useful for automated setups, Docker environments, or when you want to define tokens declaratively.
=== "/etc/ntfy/server.yml" The `auth-tokens` option is a list of access tokens that are automatically created/updated when the server starts.
When entries are removed, they are deleted from the database. Each entry is defined in the format `<username>:<token>[:<label>]`.
The `<username>` must be an existing, provisioned user, as defined in the `auth-users` section (see [users via the config](#users-via-the-config)).
The `<token>` is a valid access token, which must start with `tk_` and be 32 characters long (including the prefix). You can generate
random tokens using the `ntfy token generate` command. The optional `<label>` is a human-readable label for the token,
which can be used to identify it later.
Once configured, these tokens can be used to authenticate API requests just like tokens created via the CLI.
For usage examples, see [authenticate via access tokens](publish.md#access-tokens).
Here's an example:
=== "Declarative tokens in /etc/ntfy/server.yml"
``` yaml
auth-file: "/var/lib/ntfy/user.db"
auth-users:
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
- "backup-service:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
auth-tokens:
- "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76"
- "backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script"
```
=== "Declarative tokens via env variables"
```
# Comma-separated list
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76,backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script'
```
In this example, the `auth-users` section defines two users, `phil` and `backup-service`. The `auth-tokens` section
defines access tokens for these users. `phil` has a token `tk_3gd7d2yftt4b8ixyfe9mnmro88o76`, while `backup-service`
has a token `tk_f099we8uzj7xi5qshzajwp6jffvkz` with the label "Backup script".
### Example: Private instance
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`,
and to configure users in the `auth-users` section (see [users via the config](#users-via-the-config)),
access control entries in the `auth-access` section (see [ACL entries via the config](#acl-entries-via-the-config)),
and access tokens in the `auth-tokens` section (see [access tokens via the config](#tokens-via-the-config)).
Here's an example that defines a single admin user `phil` with the password `mypass`, and a regular user `backup-script`
with the password `backup-script`. The admin user has full access to all topics, while regular user can only
access the `backups` topic with read-write permissions. The `auth-default-access` is set to `deny-all`, which means
that all other users and anonymous access are denied by default.
=== "Config via /etc/ntfy/server.yml"
``` yaml ``` yaml
auth-file: "/var/lib/ntfy/user.db" auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all" auth-default-access: "deny-all"
auth-users:
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
- "backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user"
auth-access:
- "backup-service:backups:rw"
auth-tokens:
- "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token"
``` ```
After that, simply create an `admin` user: === "Config via env variables"
``` yaml
``` NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
$ ntfy user add --role=admin phil NTFY_AUTH_DEFAULT_ACCESS='deny-all'
password: mypass NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user'
confirm: mypass NTFY_AUTH_ACCESS='backup-service:backups:rw'
user phil added with role admin NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token'
``` ```
Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example: with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password.
Here's a simple example (using the credentials of the `phil` user):
=== "Command line (curl)" === "Command line (curl)"
``` ```

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

@@ -96,6 +96,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies. - [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in. - [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails - [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.
## Projects + scripts ## Projects + scripts
@@ -175,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,12 +1468,14 @@ 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:** **Bug fixes + maintenance:**
* Enhanced JSON webhook support via [pre-defined](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) ([#1390](https://github.com/binwiederhier/ntfy/pull/1390)) * 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))
* 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) * 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)

22
go.mod
View File

@@ -6,13 +6,13 @@ toolchain go1.24.0
require ( require (
cloud.google.com/go/firestore v1.18.0 // indirect cloud.google.com/go/firestore v1.18.0 // indirect
cloud.google.com/go/storage v1.55.0 // indirect cloud.google.com/go/storage v1.56.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.18.0 github.com/emersion/go-smtp v0.18.0
github.com/gabriel-vasile/mimetype v1.4.9 github.com/gabriel-vasile/mimetype v1.4.9
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.28 github.com/mattn/go-sqlite3 v1.14.30
github.com/olebedev/when v1.1.0 github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.7 github.com/urfave/cli/v2 v2.27.7
@@ -21,7 +21,7 @@ require (
golang.org/x/sync v0.16.0 golang.org/x/sync v0.16.0
golang.org/x/term v0.33.0 golang.org/x/term v0.33.0
golang.org/x/time v0.12.0 golang.org/x/time v0.12.0
google.golang.org/api v0.242.0 google.golang.org/api v0.244.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
@@ -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,11 +61,11 @@ 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
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
@@ -95,10 +95,10 @@ 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-20250715232539-7130f93afb79 // indirect google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.73.0 // 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
) )

75
go.sum
View File

@@ -1,11 +1,7 @@
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo=
cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=
cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs= cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs=
cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s= cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
@@ -22,14 +18,12 @@ cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFs
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0= cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= 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.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4= firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
firebase.google.com/go/v4 v4.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE=
firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
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=
@@ -76,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=
@@ -87,10 +81,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -106,8 +98,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -122,8 +112,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -137,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=
@@ -194,8 +184,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -212,8 +200,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@@ -225,8 +211,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -241,8 +225,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@@ -254,8 +236,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -269,8 +249,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
@@ -283,27 +261,18 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
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-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b h1:eZTgydvqZO44zyTZAvMaSyAxccZZdraiSAGvqOczVvk=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:suyz2QBHQKlGIF92HEEsCfO1SwxXdk7PFLz+Zd9Uah4=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs= google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
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=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=

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

@@ -95,6 +95,9 @@ type Config struct {
AuthFile string AuthFile string
AuthStartupQueries string AuthStartupQueries string
AuthDefault user.Permission AuthDefault user.Permission
AuthUsers []*user.User
AuthAccess map[string][]*user.Grant
AuthTokens map[string][]*user.Token
AuthBcryptCost int AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration AuthStatsQueueWriterInterval time.Duration
AttachmentCacheDir string AttachmentCacheDir string

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

@@ -6,7 +6,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/netip" "net/netip"
"path/filepath"
"strings" "strings"
"sync"
"time" "time"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
@@ -282,10 +284,17 @@ 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
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) { func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
// Check the parent directory of the database file (makes for friendly error messages)
parentDir := filepath.Dir(filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
}
// Open database
db, err := sql.Open("sqlite3", filename) db, err := sql.Open("sqlite3", filename)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -340,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
} }
@@ -521,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
} }
@@ -566,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
@@ -580,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
@@ -614,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
@@ -759,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

@@ -10,6 +10,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/payments"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -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)
@@ -196,7 +197,18 @@ func New(conf *Config) (*Server, error) {
} }
var userManager *user.Manager var userManager *user.Manager
if conf.AuthFile != "" { if conf.AuthFile != "" {
userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval) authConfig := &user.Config{
Filename: conf.AuthFile,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
Users: conf.AuthUsers,
Access: conf.AuthAccess,
Tokens: conf.AuthTokens,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
}
userManager, err = user.NewManager(authConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -991,7 +1003,12 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
} }
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") template = templateMode(readParam(r, "x-template", "template", "tpl"))
messageStr := readParam(r, "x-message", "message", "m")
if !template.InlineMode() {
// Convert "\n" to literal newline everything but inline mode
messageStr = strings.ReplaceAll(messageStr, "\\n", "\n")
}
if messageStr != "" { if messageStr != "" {
m.Message = messageStr m.Message = messageStr
} }
@@ -1033,7 +1050,6 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
if markdown || strings.ToLower(contentType) == "text/markdown" { if markdown || strings.ToLower(contentType) == "text/markdown" {
m.ContentType = "text/markdown" m.ContentType = "text/markdown"
} }
template = templateMode(readParam(r, "x-template", "template", "tpl"))
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
contentEncoding := readParam(r, "content-encoding") contentEncoding := readParam(r, "content-encoding")
if unifiedpush || contentEncoding == "aes128gcm" { if unifiedpush || contentEncoding == "aes128gcm" {
@@ -1119,8 +1135,8 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM
return errHTTPEntityTooLargeJSONBody return errHTTPEntityTooLargeJSONBody
} }
peekedBody := strings.TrimSpace(string(body.PeekedBytes)) peekedBody := strings.TrimSpace(string(body.PeekedBytes))
if templateName := template.Name(); templateName != "" { if template.FileMode() {
if err := s.renderTemplateFromFile(m, templateName, peekedBody); err != nil { if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
return err return err
} }
} else { } else {
@@ -1198,7 +1214,7 @@ func (s *Server) renderTemplate(tpl string, source string) (string, error) {
if err := t.Execute(limitWriter, data); err != nil { if err := t.Execute(limitWriter, data); err != nil {
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
} }
return strings.TrimSpace(buf.String()), nil return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
} }
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {

View File

@@ -82,6 +82,14 @@
# set to "read-write" (default), "read-only", "write-only" or "deny-all". # set to "read-write" (default), "read-only", "write-only" or "deny-all".
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable # - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
# WAL mode. This is similar to cache-startup-queries. See above for details. # WAL mode. This is similar to cache-startup-queries. See above for details.
# - auth-users is a list of users that are automatically created when the server starts.
# Each entry is in the format "<username>:<password-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
# Use 'ntfy user hash' to generate the password hash from a password.
# - auth-access is a list of access control entries that are automatically created when the server starts.
# Each entry is in the format "<username>:<topic-pattern>:<access>", e.g. "phil:mytopic:rw" or "phil:phil-*:rw".
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
# Use 'ntfy token generate' to generate a new access token.
# #
# Debian/RPM package users: # Debian/RPM package users:
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package # Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
@@ -94,6 +102,9 @@
# auth-file: <filename> # auth-file: <filename>
# auth-default-access: "read-write" # auth-default-access: "read-write"
# auth-startup-queries: # auth-startup-queries:
# auth-users:
# auth-access:
# auth-tokens:
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine # If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
# the visitor IP address instead of the remote address of the connection. # the visitor IP address instead of the remote address of the connection.

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())
@@ -234,7 +245,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
"token_expires": expires, "token_expires": expires,
}). }).
Debug("Creating token for user %s", u.Name) Debug("Creating token for user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP()) token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP(), false)
if err != nil { if err != nil {
return err return err
} }
@@ -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

@@ -176,7 +176,7 @@ func TestAccount_ChangeSettings(t *testing.T) {
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, _ := s.userManager.User("phil") u, _ := s.userManager.User("phil")
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified()) token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false)
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{ rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
@@ -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

@@ -25,7 +25,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
for i, g := range grants[u.ID] { for i, g := range grants[u.ID] {
userGrants[i] = &apiUserGrantResponse{ userGrants[i] = &apiUserGrantResponse{
Topic: g.TopicPattern, Topic: g.TopicPattern,
Permission: g.Allow.String(), Permission: g.Permission.String(),
} }
} }
usersResponse[i] = &apiUserResponse{ usersResponse[i] = &apiUserResponse{

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
@@ -3069,6 +3044,61 @@ func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) {
require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code) require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code)
} }
func TestServer_MessageTemplate_InlineNewlines(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{
"X-Message": `{{"New\nlines"}}`,
"X-Title": `{{"New\nlines"}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, `New
lines`, m.Message)
require.Equal(t, `New
lines`, m.Title)
}
func TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{
"X-Message": `{{.foo}}{{"\n"}}{{.food}}`,
"X-Title": `{{.food}}{{"\n"}}{{.foo}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, `bar
bag`, m.Message)
require.Equal(t, `bag
bar`, m.Title)
}
func TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) {
t.Parallel()
c := newTestConfig(t)
c.TemplateDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(`
title: |
{{.food}}{{"\n"}}{{.foo}}
message: |
{{.foo}}{{"\n"}}{{.food}}
`), 0644))
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic?template=newline", `{"foo":"bar","food":"bag"}`, nil)
fmt.Println(response.Body.String())
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, `bar
bag`, m.Message)
require.Equal(t, `bag
bar`, m.Title)
}
var ( var (
//go:embed testdata/webhook_github_comment_created.json //go:embed testdata/webhook_github_comment_created.json
githubCommentCreatedJSON string githubCommentCreatedJSON string
@@ -3202,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

@@ -245,19 +245,46 @@ func (q *queryFilter) Pass(msg *message) bool {
return true return true
} }
// templateMode represents the mode in which templates are used
//
// It can be
// - empty: templating is disabled
// - a boolean string (yes/1/true/no/0/false): inline-templating mode
// - a filename (e.g. grafana): template mode with a file
type templateMode string type templateMode string
// Enabled returns true if templating is enabled
func (t templateMode) Enabled() bool { func (t templateMode) Enabled() bool {
return t != "" return t != ""
} }
func (t templateMode) Name() string { // InlineMode returns true if inline-templating mode is enabled
if isBoolValue(string(t)) { func (t templateMode) InlineMode() bool {
return "" return t.Enabled() && isBoolValue(string(t))
}
return string(t)
} }
// FileMode returns true if file-templating mode is enabled
func (t templateMode) FileMode() bool {
return t.Enabled() && !isBoolValue(string(t))
}
// FileName returns the filename if file-templating mode is enabled, or an empty string otherwise
func (t templateMode) FileName() string {
if t.FileMode() {
return string(t)
}
return ""
}
// templateFile represents a template file with title and message
// It is used for file-based templates, e.g. grafana, influxdb, etc.
//
// Example YAML:
//
// title: "Alert: {{ .Title }}"
// message: |
// This is a {{ .Type }} alert.
// It can be multiline.
type templateFile struct { type templateFile struct {
Title *string `yaml:"title"` Title *string `yaml:"title"`
Message *string `yaml:"message"` Message *string `yaml:"message"`
@@ -338,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 {
@@ -399,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"`

View File

@@ -7,11 +7,13 @@ 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"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -75,6 +77,7 @@ const (
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}', prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL, sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0), stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0), stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0), stats_calls INT NOT NULL DEFAULT (0),
@@ -97,6 +100,7 @@ const (
read INT NOT NULL, read INT NOT NULL,
write INT NOT NULL, write INT NOT NULL,
owner_user_id INT, owner_user_id INT,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, topic), PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
@@ -108,9 +112,11 @@ const (
last_access INT NOT NULL, last_access INT NOT NULL,
last_origin TEXT NOT NULL, last_origin TEXT NOT NULL,
expires INT NOT NULL, expires INT NOT NULL,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, token), PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
); );
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
CREATE TABLE IF NOT EXISTS user_phone ( CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
phone_number TEXT NOT NULL, phone_number TEXT NOT NULL,
@@ -121,8 +127,8 @@ const (
id INT PRIMARY KEY, id INT PRIMARY KEY,
version INT NOT NULL version INT NOT NULL
); );
INSERT INTO user (id, user, pass, role, sync_topic, created) INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH()) VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
COMMIT; COMMIT;
` `
@@ -132,26 +138,26 @@ const (
` `
selectUserByIDQuery = ` selectUserByIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = ? WHERE u.id = ?
` `
selectUserByNameQuery = ` selectUserByNameQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ? WHERE user = ?
` `
selectUserByTokenQuery = ` selectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
JOIN user_token tk on u.id = tk.user_id JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
` `
selectUserByStripeCustomerIDQuery = ` selectUserByStripeCustomerIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ? WHERE u.stripe_customer_id = ?
@@ -165,8 +171,8 @@ const (
` `
insertUserQuery = ` insertUserQuery = `
INSERT INTO user (id, user, pass, role, sync_topic, created) INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
` `
selectUsernamesQuery = ` selectUsernamesQuery = `
SELECT user SELECT user
@@ -179,8 +185,10 @@ const (
END, user END, user
` `
selectUserCountQuery = `SELECT COUNT(*) FROM user` selectUserCountQuery = `SELECT COUNT(*) FROM user`
selectUserIDFromUsernameQuery = `SELECT id FROM user WHERE user = ?`
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
updateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?`
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
@@ -189,18 +197,18 @@ const (
deleteUserQuery = `DELETE FROM user WHERE user = ?` deleteUserQuery = `DELETE FROM user WHERE user = ?`
upsertUserAccessQuery = ` upsertUserAccessQuery = `
INSERT INTO user_access (user_id, topic, read, write, owner_user_id) INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?)))) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?)
ON CONFLICT (user_id, topic) ON CONFLICT (user_id, topic)
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned
` `
selectUserAllAccessQuery = ` selectUserAllAccessQuery = `
SELECT user_id, topic, read, write SELECT user_id, topic, read, write, provisioned
FROM user_access FROM user_access
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
` `
selectUserAccessQuery = ` selectUserAccessQuery = `
SELECT topic, read, write SELECT topic, read, write, provisioned
FROM user_access FROM user_access
WHERE user_id = (SELECT id FROM user WHERE user = ?) WHERE user_id = (SELECT id FROM user WHERE user = ?)
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
@@ -244,6 +252,7 @@ const (
WHERE user_id = (SELECT id FROM user WHERE user = ?) WHERE user_id = (SELECT id FROM user WHERE user = ?)
OR owner_user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)
` `
deleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = 1`
deleteTopicAccessQuery = ` deleteTopicAccessQuery = `
DELETE FROM user_access DELETE FROM user_access
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)) WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
@@ -251,13 +260,20 @@ const (
` `
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?` selectTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ?`
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?` selectTokenQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?`
insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)` selectAllProvisionedTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1`
upsertTokenQuery = `
INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, token)
DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned;
`
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
deleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = ?`
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
deleteExcessTokensQuery = ` deleteExcessTokensQuery = `
@@ -312,7 +328,7 @@ const (
// Schema management queries // Schema management queries
const ( const (
currentSchemaVersion = 5 currentSchemaVersion = 6
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@@ -427,6 +443,100 @@ const (
migrate4To5UpdateQueries = ` migrate4To5UpdateQueries = `
UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
` `
// 5 -> 6
migrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
INSERT INTO user
SELECT
id,
tier_id,
user,
pass,
role,
prefs,
sync_topic,
0, -- provisioned
stats_messages,
stats_emails,
stats_calls,
stripe_customer_id,
stripe_subscription_id,
stripe_subscription_status,
stripe_subscription_interval,
stripe_subscription_paid_until,
stripe_subscription_cancel_at,
created,
deleted
FROM user_old;
DROP TABLE user_old;
-- Alter user_access table: Add provisioned column
ALTER TABLE user_access RENAME TO user_access_old;
CREATE TABLE user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INTEGER NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
DROP TABLE user_access_old;
-- Alter user_token table: Add provisioned column
ALTER TABLE user_token RENAME TO user_token_old;
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_token SELECT *, 0 FROM user_token_old;
DROP TABLE user_token_old;
-- Recreate indices
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
-- Re-enable foreign keys
PRAGMA foreign_keys=on;
`
) )
var ( var (
@@ -435,42 +545,70 @@ var (
2: migrateFrom2, 2: migrateFrom2,
3: migrateFrom3, 3: migrateFrom3,
4: migrateFrom4, 4: migrateFrom4,
5: migrateFrom5,
} }
) )
// Manager is an implementation of Manager. It stores users and access control list // Manager is an implementation of Manager. It stores users and access control list
// in a SQLite database. // in a SQLite database.
type Manager struct { type Manager struct {
config *Config
db *sql.DB db *sql.DB
defaultAccess Permission // Default permission if no ACL matches
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
bcryptCost int // Makes testing easier
mu sync.Mutex mu sync.Mutex
} }
// Config holds the configuration for the user Manager
type Config struct {
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db"
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
Users []*User // Predefined users to create on startup
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
}
var _ Auther = (*Manager)(nil) var _ Auther = (*Manager)(nil)
// NewManager creates a new Manager instance // NewManager creates a new Manager instance
func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) { func NewManager(config *Config) (*Manager, error) {
db, err := sql.Open("sqlite3", filename) // Set defaults
if config.BcryptCost <= 0 {
config.BcryptCost = DefaultUserPasswordBcryptCost
}
if config.QueueWriterInterval.Seconds() <= 0 {
config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval
}
// Check the parent directory of the database file (makes for friendly error messages)
parentDir := filepath.Dir(config.Filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir)
}
// Open DB and run setup queries
db, err := sql.Open("sqlite3", config.Filename)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := setupDB(db); err != nil { if err := setupDB(db); err != nil {
return nil, err return nil, err
} }
if err := runStartupQueries(db, startupQueries); err != nil { if err := runStartupQueries(db, config.StartupQueries); err != nil {
return nil, err return nil, err
} }
manager := &Manager{ manager := &Manager{
db: db, db: db,
defaultAccess: defaultAccess, config: config,
statsQueue: make(map[string]*Stats), statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate), tokenQueue: make(map[string]*TokenUpdate),
bcryptCost: bcryptCost,
} }
go manager.asyncQueueWriter(queueWriterInterval) if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil {
return nil, err
}
go manager.asyncQueueWriter(config.QueueWriterInterval)
return manager, nil return manager, nil
} }
@@ -515,15 +653,15 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) {
// CreateToken generates a random token for the given user and returns it. The token expires // CreateToken generates a random token for the given user and returns it. The token expires
// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the // after a fixed duration unless ChangeToken is called. This function also prunes tokens for the
// given user, if there are too many of them. // given user, if there are too many of them.
func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr) (*Token, error) { func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) {
token := util.RandomLowerStringPrefix(tokenPrefix, tokenLength) // Lowercase only to support "<topic>+<token>@<domain>" email addresses return queryTx(a.db, func(tx *sql.Tx) (*Token, error) {
tx, err := a.db.Begin() return a.createTokenTx(tx, userID, GenerateToken(), label, expires, origin, provisioned)
if err != nil { })
return nil, err
} }
defer tx.Rollback()
func (a *Manager) createTokenTx(tx *sql.Tx, userID, token, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) {
access := time.Now() access := time.Now()
if _, err := tx.Exec(insertTokenQuery, userID, token, label, access.Unix(), origin.String(), expires.Unix()); err != nil { if _, err := tx.Exec(upsertTokenQuery, userID, token, label, access.Unix(), origin.String(), expires.Unix(), provisioned); err != nil {
return nil, err return nil, err
} }
rows, err := tx.Query(selectTokenCountQuery, userID) rows, err := tx.Query(selectTokenCountQuery, userID)
@@ -545,15 +683,13 @@ func (a *Manager) CreateToken(userID, label string, expires time.Time, origin ne
return nil, err return nil, err
} }
} }
if err := tx.Commit(); err != nil {
return nil, err
}
return &Token{ return &Token{
Value: token, Value: token,
Label: label, Label: label,
LastAccess: access, LastAccess: access,
LastOrigin: origin, LastOrigin: origin,
Expires: expires, Expires: expires,
Provisioned: provisioned,
}, nil }, nil
} }
@@ -567,7 +703,26 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) {
tokens := make([]*Token, 0) tokens := make([]*Token, 0)
for { for {
token, err := a.readToken(rows) token, err := a.readToken(rows)
if err == ErrTokenNotFound { if errors.Is(err, ErrTokenNotFound) {
break
} else if err != nil {
return nil, err
}
tokens = append(tokens, token)
}
return tokens, nil
}
func (a *Manager) allProvisionedTokens() ([]*Token, error) {
rows, err := a.db.Query(selectAllProvisionedTokensQuery)
if err != nil {
return nil, err
}
defer rows.Close()
tokens := make([]*Token, 0)
for {
token, err := a.readToken(rows)
if errors.Is(err, ErrTokenNotFound) {
break break
} else if err != nil { } else if err != nil {
return nil, err return nil, err
@@ -590,10 +745,11 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
func (a *Manager) readToken(rows *sql.Rows) (*Token, error) { func (a *Manager) readToken(rows *sql.Rows) (*Token, error) {
var token, label, lastOrigin string var token, label, lastOrigin string
var lastAccess, expires int64 var lastAccess, expires int64
var provisioned bool
if !rows.Next() { if !rows.Next() {
return nil, ErrTokenNotFound return nil, ErrTokenNotFound
} }
if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires); err != nil { if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil {
return nil, err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@@ -608,6 +764,7 @@ func (a *Manager) readToken(rows *sql.Rows) (*Token, error) {
LastAccess: time.Unix(lastAccess, 0), LastAccess: time.Unix(lastAccess, 0),
LastOrigin: lastOriginIP, LastOrigin: lastOriginIP,
Expires: time.Unix(expires, 0), Expires: time.Unix(expires, 0),
Provisioned: provisioned,
}, nil }, nil
} }
@@ -616,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
@@ -639,15 +799,35 @@ 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 a.removeTokenTx(tx, userID, token)
})
}
func (a *Manager) removeTokenTx(tx *sql.Tx, userID, token string) error {
if token == "" { if token == "" {
return errNoTokenProvided return errNoTokenProvided
} }
if _, err := a.db.Exec(deleteTokenQuery, userID, token); err != nil { if _, err := tx.Exec(deleteTokenQuery, userID, token); err != nil {
return err return err
} }
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 {
@@ -666,7 +846,7 @@ func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
phoneNumbers := make([]string, 0) phoneNumbers := make([]string, 0)
for { for {
phoneNumber, err := a.readPhoneNumber(rows) phoneNumber, err := a.readPhoneNumber(rows)
if err == ErrPhoneNumberNotFound { if errors.Is(err, ErrPhoneNumberNotFound) {
break break
} else if err != nil { } else if err != nil {
return nil, err return nil, err
@@ -816,13 +996,20 @@ func (a *Manager) writeTokenUpdateQueue() error {
log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue)) log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue))
for tokenID, update := range tokenQueue { for tokenID, update := range tokenQueue {
log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix()) log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix())
if _, err := tx.Exec(updateTokenLastAccessQuery, update.LastAccess.Unix(), update.LastOrigin.String(), tokenID); err != nil { if err := a.updateTokenLastAccessTx(tx, tokenID, update.LastAccess.Unix(), update.LastOrigin.String()); err != nil {
return err return err
} }
} }
return tx.Commit() return tx.Commit()
} }
func (a *Manager) updateTokenLastAccessTx(tx *sql.Tx, token string, lastAccess int64, lastOrigin string) error {
if _, err := tx.Exec(updateTokenLastAccessQuery, lastAccess, lastOrigin, token); err != nil {
return err
}
return nil
}
// Authorize returns nil if the given user has access to the given topic using the desired // Authorize returns nil if the given user has access to the given topic using the desired
// permission. The user param may be nil to signal an anonymous user. // permission. The user param may be nil to signal an anonymous user.
func (a *Manager) Authorize(user *User, topic string, perm Permission) error { func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
@@ -843,7 +1030,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
} }
defer rows.Close() defer rows.Close()
if !rows.Next() { if !rows.Next() {
return a.resolvePerms(a.defaultAccess, perm) return a.resolvePerms(a.config.DefaultAccess, perm)
} }
var read, write bool var read, write bool
if err := rows.Scan(&read, &write); err != nil { if err := rows.Scan(&read, &write); err != nil {
@@ -865,23 +1052,33 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
// AddUser adds a user with the given username, password and role // AddUser adds a user with the given username, password and role
func (a *Manager) AddUser(username, password string, role Role, hashed bool) error { func (a *Manager) AddUser(username, password string, role Role, hashed bool) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.addUserTx(tx, username, password, role, hashed, false)
})
}
// AddUser adds a user with the given username, password and role
func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, hashed, provisioned bool) error {
if !AllowedUsername(username) || !AllowedRole(role) { if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument return ErrInvalidArgument
} }
var hash []byte var hash string
var err error = nil var err error = nil
if hashed { if hashed {
hash = []byte(password) hash = password
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err
}
} else { } else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) hash, err = hashPassword(password, a.config.BcryptCost)
if err != nil { if err != nil {
return err return err
} }
} }
userID := util.RandomStringPrefix(userIDPrefix, userIDLength) userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix() syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil { if _, err = tx.Exec(insertUserQuery, userID, username, hash, role, syncTopic, provisioned, now); err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { if errors.Is(err, sqlite3.ErrConstraintUnique) {
return ErrUserExists return ErrUserExists
} }
return err return err
@@ -892,11 +1089,20 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err
// 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 a.removeUserTx(tx, username)
})
}
func (a *Manager) removeUserTx(tx *sql.Tx, username string) error {
if !AllowedUsername(username) { if !AllowedUsername(username) {
return ErrInvalidArgument return ErrInvalidArgument
} }
// Rows in user_access, user_token, etc. are deleted via foreign keys // Rows in user_access, user_token, etc. are deleted via foreign keys
if _, err := a.db.Exec(deleteUserQuery, username); err != nil { if _, err := tx.Exec(deleteUserQuery, username); err != nil {
return err return err
} }
return nil return nil
@@ -1010,13 +1216,14 @@ func (a *Manager) userByToken(token string) (*User, error) {
func (a *Manager) readUser(rows *sql.Rows) (*User, error) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close() defer rows.Close()
var id, username, hash, role, prefs, syncTopic string var id, username, hash, role, prefs, syncTopic string
var provisioned bool
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
var messages, emails, calls int64 var messages, emails, calls int64
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
if !rows.Next() { if !rows.Next() {
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@@ -1028,6 +1235,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
Role: Role(role), Role: Role(role),
Prefs: &Prefs{}, Prefs: &Prefs{},
SyncTopic: syncTopic, SyncTopic: syncTopic,
Provisioned: provisioned,
Stats: &Stats{ Stats: &Stats{
Messages: messages, Messages: messages,
Emails: emails, Emails: emails,
@@ -1036,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
}, },
@@ -1078,8 +1286,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
grants := make(map[string][]Grant, 0) grants := make(map[string][]Grant, 0)
for rows.Next() { for rows.Next() {
var userID, topic string var userID, topic string
var read, write bool var read, write, provisioned bool
if err := rows.Scan(&userID, &topic, &read, &write); err != nil { if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil {
return nil, err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@@ -1089,7 +1297,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
} }
grants[userID] = append(grants[userID], Grant{ grants[userID] = append(grants[userID], Grant{
TopicPattern: fromSQLWildcard(topic), TopicPattern: fromSQLWildcard(topic),
Allow: NewPermission(read, write), Permission: NewPermission(read, write),
Provisioned: provisioned,
}) })
} }
return grants, nil return grants, nil
@@ -1105,15 +1314,16 @@ func (a *Manager) Grants(username string) ([]Grant, error) {
grants := make([]Grant, 0) grants := make([]Grant, 0)
for rows.Next() { for rows.Next() {
var topic string var topic string
var read, write bool var read, write, provisioned bool
if err := rows.Scan(&topic, &read, &write); err != nil { if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil {
return nil, err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
} }
grants = append(grants, Grant{ grants = append(grants, Grant{
TopicPattern: fromSQLWildcard(topic), TopicPattern: fromSQLWildcard(topic),
Allow: NewPermission(read, write), Permission: NewPermission(read, write),
Provisioned: provisioned,
}) })
} }
return grants, nil return grants, nil
@@ -1199,18 +1409,41 @@ 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 {
var hash []byte if err := a.CanChangeUser(username); err != nil {
var err error return err
}
return execTx(a.db, func(tx *sql.Tx) error {
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 {
var hash string
var err error
if hashed { if hashed {
hash = []byte(password) hash = password
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err
}
} else { } else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) hash, err = hashPassword(password, a.config.BcryptCost)
if err != nil { if err != nil {
return err return err
} }
} }
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { if _, err := tx.Exec(updateUserPassQuery, hash, username); err != nil {
return err return err
} }
return nil return nil
@@ -1219,20 +1452,38 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error {
// 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 a.changeRoleTx(tx, username, role)
})
}
func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error {
if !AllowedUsername(username) || !AllowedRole(role) { if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument return ErrInvalidArgument
} }
if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { if _, err := tx.Exec(updateUserRoleQuery, string(role), username); err != nil {
return err return err
} }
if role == RoleAdmin { if role == RoleAdmin {
if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil { if _, err := tx.Exec(deleteUserAccessQuery, username, username); err != nil {
return err return err
} }
} }
return nil return nil
} }
// 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.
func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error {
if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil {
return err
}
return nil
}
// ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, // ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages,
// or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere. // or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere.
func (a *Manager) ChangeTier(username, tier string) error { func (a *Manager) ChangeTier(username, tier string) error {
@@ -1306,13 +1557,19 @@ func (a *Manager) AllowReservation(username string, topic string) error {
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry // read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
// owner may either be a user (username), or the system (empty). // owner may either be a user (username), or the system (empty).
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.allowAccessTx(tx, username, topicPattern, permission, false)
})
}
func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {
if !AllowedUsername(username) && username != Everyone { if !AllowedUsername(username) && username != Everyone {
return ErrInvalidArgument return ErrInvalidArgument
} else if !AllowedTopicPattern(topicPattern) { } else if !AllowedTopicPattern(topicPattern) {
return ErrInvalidArgument return ErrInvalidArgument
} }
owner := "" owner := ""
if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner); err != nil { if _, err := tx.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner, provisioned); err != nil {
return err return err
} }
return nil return nil
@@ -1321,19 +1578,25 @@ func (a *Manager) AllowAccess(username string, topicPattern string, permission P
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
// empty) for an entire user. The parameter topicPattern may include wildcards (*). // empty) for an entire user. The parameter topicPattern may include wildcards (*).
func (a *Manager) ResetAccess(username string, topicPattern string) error { func (a *Manager) ResetAccess(username string, topicPattern string) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.resetAccessTx(tx, username, topicPattern)
})
}
func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error {
if !AllowedUsername(username) && username != Everyone && username != "" { if !AllowedUsername(username) && username != Everyone && username != "" {
return ErrInvalidArgument return ErrInvalidArgument
} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
return ErrInvalidArgument return ErrInvalidArgument
} }
if username == "" && topicPattern == "" { if username == "" && topicPattern == "" {
_, err := a.db.Exec(deleteAllAccessQuery, username) _, err := tx.Exec(deleteAllAccessQuery, username)
return err return err
} else if topicPattern == "" { } else if topicPattern == "" {
_, err := a.db.Exec(deleteUserAccessQuery, username, username) _, err := tx.Exec(deleteUserAccessQuery, username, username)
return err return err
} }
_, err := a.db.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern)) _, err := tx.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern))
return err return err
} }
@@ -1349,10 +1612,10 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username); err != nil { if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username, false); err != nil {
return err return err
} }
if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username); err != nil { if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
@@ -1387,7 +1650,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
// DefaultAccess returns the default read/write access if no access control entry matches // DefaultAccess returns the default read/write access if no access control entry matches
func (a *Manager) DefaultAccess() Permission { func (a *Manager) DefaultAccess() Permission {
return a.defaultAccess return a.config.DefaultAccess
} }
// AddTier creates a new tier in the database // AddTier creates a new tier in the database
@@ -1439,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
@@ -1505,6 +1768,147 @@ func (a *Manager) Close() error {
return a.db.Close() return a.db.Close()
} }
// maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config.
func (a *Manager) maybeProvisionUsersAccessAndTokens() error {
if !a.config.ProvisionEnabled {
return nil
}
existingUsers, err := a.Users()
if err != nil {
return err
}
provisionUsernames := util.Map(a.config.Users, func(u *User) string {
return u.Name
})
return execTx(a.db, func(tx *sql.Tx) error {
if err := a.maybeProvisionUsers(tx, provisionUsernames, existingUsers); err != nil {
return fmt.Errorf("failed to provision users: %v", err)
}
if err := a.maybeProvisionGrants(tx); err != nil {
return fmt.Errorf("failed to provision grants: %v", err)
}
if err := a.maybeProvisionTokens(tx, provisionUsernames); err != nil {
return fmt.Errorf("failed to provision tokens: %v", err)
}
return nil
})
}
// maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them.
// It also removes users that are provisioned, but not in the config anymore.
func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, existingUsers []*User) error {
// Remove users that are provisioned, but not in the config anymore
for _, user := range existingUsers {
if user.Name == Everyone {
continue
} else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) {
if err := a.removeUserTx(tx, user.Name); err != nil {
return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err)
}
}
}
// Add or update provisioned users
for _, user := range a.config.Users {
if user.Name == Everyone {
continue
}
existingUser, exists := util.Find(existingUsers, func(u *User) bool {
return u.Name == user.Name
})
if !exists {
if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) {
return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err)
}
} else {
if !existingUser.Provisioned {
if err := a.changeProvisionedTx(tx, user.Name, true); err != nil {
return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err)
}
}
if existingUser.Hash != user.Hash {
if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil {
return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err)
}
}
if existingUser.Role != user.Role {
if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil {
return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err)
}
}
}
}
return nil
}
// maybyProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config.
//
// Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last
// access time) or do not have dependent resources (such as grants or tokens).
func (a *Manager) maybeProvisionGrants(tx *sql.Tx) error {
// Remove all provisioned grants
if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil {
return err
}
// (Re-)add provisioned grants
for username, grants := range a.config.Access {
user, exists := util.Find(a.config.Users, func(u *User) bool {
return u.Name == username
})
if !exists && username != Everyone {
return fmt.Errorf("user %s is not a provisioned user, refusing to add ACL entry", username)
} else if user != nil && user.Role == RoleAdmin {
return fmt.Errorf("adding access control entries is not allowed for admin roles for user %s", username)
}
for _, grant := range grants {
if err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil {
return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err)
}
if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil {
return err
}
}
}
return nil
}
func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string) error {
// Remove tokens that are provisioned, but not in the config anymore
existingTokens, err := a.allProvisionedTokens()
if err != nil {
return fmt.Errorf("failed to retrieve existing provisioned tokens: %v", err)
}
var provisionTokens []string
for _, userTokens := range a.config.Tokens {
for _, token := range userTokens {
provisionTokens = append(provisionTokens, token.Value)
}
}
for _, existingToken := range existingTokens {
if !slices.Contains(provisionTokens, existingToken.Value) {
if _, err := tx.Exec(deleteProvisionedTokenQuery, existingToken.Value); err != nil {
return fmt.Errorf("failed to remove provisioned token %s: %v", existingToken.Value, err)
}
}
}
// (Re-)add provisioned tokens
for username, tokens := range a.config.Tokens {
if !slices.Contains(provisionUsernames, username) && username != Everyone {
return fmt.Errorf("user %s is not a provisioned user, refusing to add tokens", username)
}
var userID string
row := tx.QueryRow(selectUserIDFromUsernameQuery, username)
if err := row.Scan(&userID); err != nil {
return fmt.Errorf("failed to find provisioned user %s for provisioned tokens", username)
}
for _, token := range tokens {
if _, err := a.createTokenTx(tx, userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), true); err != nil {
return err
}
}
}
return nil
}
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards, // toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
// and escapes '_', assuming '\' as escape character. // and escapes '_', assuming '\' as escape character.
func toSQLWildcard(s string) string { func toSQLWildcard(s string) string {
@@ -1676,6 +2080,22 @@ func migrateFrom4(db *sql.DB) error {
return tx.Commit() return tx.Commit()
} }
func migrateFrom5(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 6); err != nil {
return err
}
return tx.Commit()
}
func nullString(s string) sql.NullString { func nullString(s string) sql.NullString {
if s == "" { if s == "" {
return sql.NullString{} return sql.NullString{}
@@ -1689,3 +2109,35 @@ func nullInt64(v int64) sql.NullInt64 {
} }
return sql.NullInt64{Int64: v, Valid: true} return sql.NullInt64{Int64: v, Valid: true}
} }
// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back.
func execTx(db *sql.DB, f func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := f(tx); err != nil {
return err
}
return tx.Commit()
}
// queryTx executes a function in a transaction and returns the result. If the function
// returns an error, the transaction is rolled back.
func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) {
tx, err := db.Begin()
if err != nil {
var zero T
return zero, err
}
defer tx.Rollback()
t, err := f(tx)
if err != nil {
return t, err
}
if err := tx.Commit(); err != nil {
return t, err
}
return t, nil
}

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"
@@ -52,10 +51,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
benGrants, err := a.Grants("ben") benGrants, err := a.Grants("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []Grant{ require.Equal(t, []Grant{
{"everyonewrite", PermissionDenyAll}, {"everyonewrite", PermissionDenyAll, false},
{"mytopic", PermissionReadWrite}, {"mytopic", PermissionReadWrite, false},
{"writeme", PermissionWrite}, {"writeme", PermissionWrite, false},
{"readme", PermissionRead}, {"readme", PermissionRead, false},
}, benGrants) }, benGrants)
john, err := a.Authenticate("john", "john") john, err := a.Authenticate("john", "john")
@@ -67,10 +66,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
johnGrants, err := a.Grants("john") johnGrants, err := a.Grants("john")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []Grant{ require.Equal(t, []Grant{
{"mytopic_deny*", PermissionDenyAll}, {"mytopic_deny*", PermissionDenyAll, false},
{"mytopic_ro*", PermissionRead}, {"mytopic_ro*", PermissionRead, false},
{"mytopic*", PermissionReadWrite}, {"mytopic*", PermissionReadWrite, false},
{"*", PermissionRead}, {"*", PermissionRead, false},
}, johnGrants) }, johnGrants)
notben, err := a.Authenticate("ben", "this is wrong") notben, err := a.Authenticate("ben", "this is wrong")
@@ -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),
})) }))
@@ -194,7 +193,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.False(t, u.Deleted) require.False(t, u.Deleted)
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified()) token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
u, err = a.Authenticate("user", "pass") u, err = a.Authenticate("user", "pass")
@@ -241,7 +240,7 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
u, err := a.User("user") u, err := a.User("user")
require.Nil(t, err) require.Nil(t, err)
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified()) token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, token.Value, strings.ToLower(token.Value)) require.Equal(t, token.Value, strings.ToLower(token.Value))
} }
@@ -277,10 +276,10 @@ func TestManager_UserManagement(t *testing.T) {
benGrants, err := a.Grants("ben") benGrants, err := a.Grants("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []Grant{ require.Equal(t, []Grant{
{"everyonewrite", PermissionDenyAll}, {"everyonewrite", PermissionDenyAll, false},
{"mytopic", PermissionReadWrite}, {"mytopic", PermissionReadWrite, false},
{"writeme", PermissionWrite}, {"writeme", PermissionWrite, false},
{"readme", PermissionRead}, {"readme", PermissionRead, false},
}, benGrants) }, benGrants)
everyone, err := a.User(Everyone) everyone, err := a.User(Everyone)
@@ -292,8 +291,8 @@ func TestManager_UserManagement(t *testing.T) {
everyoneGrants, err := a.Grants(Everyone) everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []Grant{ require.Equal(t, []Grant{
{"everyonewrite", PermissionReadWrite}, {"everyonewrite", PermissionReadWrite, false},
{"announcements", PermissionRead}, {"announcements", PermissionRead, false},
}, everyoneGrants) }, everyoneGrants)
// Ben: Before revoking // Ben: Before revoking
@@ -340,7 +339,7 @@ func TestManager_UserManagement(t *testing.T) {
func TestManager_ChangePassword(t *testing.T) { func TestManager_ChangePassword(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
require.Nil(t, a.AddUser("jane", "$2b$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true)) require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
_, err := a.Authenticate("phil", "phil") _, err := a.Authenticate("phil", "phil")
require.Nil(t, err) require.Nil(t, err)
@@ -354,7 +353,7 @@ func TestManager_ChangePassword(t *testing.T) {
_, err = a.Authenticate("phil", "newpass") _, err = a.Authenticate("phil", "newpass")
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, a.ChangePassword("jane", "$2b$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true)) require.Nil(t, a.ChangePassword("jane", "$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true))
_, err = a.Authenticate("jane", "jane") _, err = a.Authenticate("jane", "jane")
require.Equal(t, ErrUnauthenticated, err) require.Equal(t, ErrUnauthenticated, err)
_, err = a.Authenticate("jane", "newpass") _, err = a.Authenticate("jane", "newpass")
@@ -489,12 +488,12 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
benGrants, err := a.Grants("ben") benGrants, err := a.Grants("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 1, len(benGrants)) require.Equal(t, 1, len(benGrants))
require.Equal(t, PermissionReadWrite, benGrants[0].Allow) require.Equal(t, PermissionReadWrite, benGrants[0].Permission)
everyoneGrants, err := a.Grants(Everyone) everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 1, len(everyoneGrants)) require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow) require.Equal(t, PermissionDenyAll, everyoneGrants[0].Permission)
benReservations, err := a.Reservations("ben") benReservations, err := a.Reservations("ben")
require.Nil(t, err) require.Nil(t, err)
@@ -523,7 +522,7 @@ func TestManager_Token_Valid(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
// Create token for user // Create token for user
token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
require.Equal(t, "some label", token.Label) require.Equal(t, "some label", token.Label)
@@ -586,12 +585,12 @@ func TestManager_Token_Expire(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
// Create tokens for user // Create tokens for user
token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token1.Value) require.NotEmpty(t, token1.Value)
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix()) require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token2.Value) require.NotEmpty(t, token2.Value)
require.NotEqual(t, token1.Value, token2.Value) require.NotEqual(t, token1.Value, token2.Value)
@@ -638,7 +637,7 @@ func TestManager_Token_Extend(t *testing.T) {
require.Equal(t, errNoTokenProvided, err) require.Equal(t, errNoTokenProvided, err)
// Create token for user // Create token for user
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
@@ -668,12 +667,12 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
// Create 2 tokens for phil // Create 2 tokens for phil
philTokens := make([]string, 0) philTokens := make([]string, 0)
token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
philTokens = append(philTokens, token.Value) philTokens = append(philTokens, token.Value)
token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified()) token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
philTokens = append(philTokens, token.Value) philTokens = append(philTokens, token.Value)
@@ -682,7 +681,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
baseTime := time.Now().Add(24 * time.Hour) baseTime := time.Now().Add(24 * time.Hour)
benTokens := make([]string, 0) benTokens := make([]string, 0)
for i := 0; i < 62; i++ { // for i := 0; i < 62; i++ { //
token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified()) token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
benTokens = append(benTokens, token.Value) benTokens = append(benTokens, token.Value)
@@ -731,7 +730,14 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
} }
func TestManager_EnqueueStats_ResetStats(t *testing.T) { func TestManager_EnqueueStats_ResetStats(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond) conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 1500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -773,7 +779,14 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) {
} }
func TestManager_EnqueueTokenUpdate(t *testing.T) { func TestManager_EnqueueTokenUpdate(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond) conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -781,7 +794,7 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
u, err := a.User("ben") u, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified()) token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err) require.Nil(t, err)
// Queue token update // Queue token update
@@ -806,7 +819,14 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
} }
func TestManager_ChangeSettings(t *testing.T) { func TestManager_ChangeSettings(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond) conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 1500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -1075,6 +1095,237 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) {
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite)) require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite))
} }
func TestManager_WithProvisionedUsers(t *testing.T) {
f := filepath.Join(t.TempDir(), "user.db")
conf := &Config{
Filename: f,
DefaultAccess: PermissionReadWrite,
ProvisionEnabled: true,
Users: []*User{
{Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
{Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin},
},
Access: map[string][]*Grant{
"philuser": {
{TopicPattern: "stats", Permission: PermissionReadWrite},
{TopicPattern: "secret", Permission: PermissionRead},
},
},
Tokens: map[string][]*Token{
"philuser": {
{Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token"},
},
},
}
a, err := NewManager(conf)
require.Nil(t, err)
// Manually add user
require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false))
// Check that the provisioned users are there
users, err := a.Users()
require.Nil(t, err)
require.Len(t, users, 4)
require.Equal(t, "philadmin", users[0].Name)
require.Equal(t, RoleAdmin, users[0].Role)
require.Equal(t, "philmanual", users[1].Name)
require.Equal(t, RoleUser, users[1].Role)
require.Equal(t, "philuser", users[2].Name)
require.Equal(t, RoleUser, users[2].Role)
require.Equal(t, "*", users[3].Name)
provisionedUserID := users[2].ID // "philuser" is the provisioned user
grants, err := a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, 2, len(grants))
require.Equal(t, "secret", grants[0].TopicPattern)
require.Equal(t, PermissionRead, grants[0].Permission)
require.Equal(t, "stats", grants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[1].Permission)
tokens, err := a.Tokens(provisionedUserID)
require.Nil(t, err)
require.Equal(t, 1, len(tokens))
require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value)
require.Equal(t, "Alerts token", tokens[0].Label)
require.True(t, tokens[0].Provisioned)
// Update the token last access time and origin (so we can check that it is persisted)
lastAccessTime := time.Now().Add(time.Hour)
lastOrigin := netip.MustParseAddr("1.1.9.9")
err = execTx(a.db, func(tx *sql.Tx) error {
return a.updateTokenLastAccessTx(tx, tokens[0].Value, lastAccessTime.Unix(), lastOrigin.String())
})
require.Nil(t, err)
// Re-open the DB (second app start)
require.Nil(t, a.db.Close())
conf.Users = []*User{
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
}
conf.Access = map[string][]*Grant{
"philuser": {
{TopicPattern: "stats12", Permission: PermissionReadWrite},
{TopicPattern: "secret12", Permission: PermissionRead},
},
}
conf.Tokens = map[string][]*Token{
"philuser": {
{Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token updated"},
{Value: "tk_u48wqendnkx9er21pqqcadlytbutx", Label: "Another token"},
},
}
a, err = NewManager(conf)
require.Nil(t, err)
// Check that the provisioned users are there
users, err = a.Users()
require.Nil(t, err)
require.Len(t, users, 3)
require.Equal(t, "philmanual", users[0].Name)
require.Equal(t, "philuser", users[1].Name)
require.Equal(t, RoleUser, users[1].Role)
require.Equal(t, RoleUser, users[0].Role)
require.Equal(t, "*", users[2].Name)
grants, err = a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, 2, len(grants))
require.Equal(t, "secret12", grants[0].TopicPattern)
require.Equal(t, PermissionRead, grants[0].Permission)
require.Equal(t, "stats12", grants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[1].Permission)
tokens, err = a.Tokens(provisionedUserID)
require.Nil(t, err)
require.Equal(t, 2, len(tokens))
require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value)
require.Equal(t, "Alerts token updated", tokens[0].Label)
require.Equal(t, lastAccessTime.Unix(), tokens[0].LastAccess.Unix())
require.Equal(t, lastOrigin, tokens[0].LastOrigin)
require.True(t, tokens[0].Provisioned)
require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value)
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)
require.Nil(t, a.db.Close())
conf.Users = []*User{}
conf.Access = map[string][]*Grant{}
conf.Tokens = map[string][]*Token{}
a, err = NewManager(conf)
require.Nil(t, err)
// Check that the provisioned users are all gone
users, err = a.Users()
require.Nil(t, err)
require.Len(t, users, 2)
require.Equal(t, "philmanual", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
require.Equal(t, "*", users[1].Name)
grants, err = a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, 0, len(grants))
tokens, err = a.Tokens(provisionedUserID)
require.Nil(t, err)
require.Equal(t, 0, len(tokens))
var count int
a.db.QueryRow("SELECT COUNT(*) FROM user WHERE provisioned = 1").Scan(&count)
require.Equal(t, 0, count)
a.db.QueryRow("SELECT COUNT(*) FROM user_grant WHERE provisioned = 1").Scan(&count)
require.Equal(t, 0, count)
a.db.QueryRow("SELECT COUNT(*) FROM user_token WHERE provisioned = 1").Scan(&count)
}
func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
f := filepath.Join(t.TempDir(), "user.db")
conf := &Config{
Filename: f,
DefaultAccess: PermissionReadWrite,
ProvisionEnabled: true,
Users: []*User{},
Access: map[string][]*Grant{
Everyone: {
{TopicPattern: "food", Permission: PermissionRead},
},
},
}
a, err := NewManager(conf)
require.Nil(t, err)
// Manually add user
require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false))
require.Nil(t, a.AllowAccess("philuser", "stats", PermissionReadWrite))
require.Nil(t, a.AllowAccess("philuser", "food", PermissionReadWrite))
users, err := a.Users()
require.Nil(t, err)
require.Len(t, users, 2)
require.Equal(t, "philuser", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
require.False(t, users[0].Provisioned) // Manually added
grants, err := a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, 2, len(grants))
require.Equal(t, "stats", grants[0].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[0].Permission)
require.False(t, grants[0].Provisioned) // Manually added
require.Equal(t, "food", grants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[1].Permission)
require.False(t, grants[1].Provisioned) // Manually added
grants, err = a.Grants(Everyone)
require.Nil(t, err)
require.Equal(t, 1, len(grants))
require.Equal(t, "food", grants[0].TopicPattern)
require.Equal(t, PermissionRead, grants[0].Permission)
require.True(t, grants[0].Provisioned) // Provisioned entry
// Re-open the DB (second app start)
require.Nil(t, a.db.Close())
conf.Users = []*User{
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
}
conf.Access = map[string][]*Grant{
"philuser": {
{TopicPattern: "stats", Permission: PermissionReadWrite},
},
}
a, err = NewManager(conf)
require.Nil(t, err)
// Check that the user was "upgraded" to a provisioned user
users, err = a.Users()
require.Nil(t, err)
require.Len(t, users, 2)
require.Equal(t, "philuser", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
require.True(t, users[0].Provisioned) // Updated to provisioned!
grants, err = a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, 2, len(grants))
require.Equal(t, "stats", grants[0].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[0].Permission)
require.True(t, grants[0].Provisioned) // Updated to provisioned!
require.Equal(t, "food", grants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[1].Permission)
require.False(t, grants[1].Provisioned) // Manually added grants stay!
grants, err = a.Grants(Everyone)
require.Nil(t, err)
require.Empty(t, grants)
}
func TestToFromSQLWildcard(t *testing.T) { func TestToFromSQLWildcard(t *testing.T) {
require.Equal(t, "up%", toSQLWildcard("up*")) require.Equal(t, "up%", toSQLWildcard("up*"))
require.Equal(t, "up\\_%", toSQLWildcard("up_*")) require.Equal(t, "up\\_%", toSQLWildcard("up_*"))
@@ -1162,16 +1413,16 @@ func TestMigrationFrom1(t *testing.T) {
require.NotEqual(t, ben.SyncTopic, phil.SyncTopic) require.NotEqual(t, ben.SyncTopic, phil.SyncTopic)
require.Equal(t, 2, len(benGrants)) require.Equal(t, 2, len(benGrants))
require.Equal(t, "secret", benGrants[0].TopicPattern) require.Equal(t, "secret", benGrants[0].TopicPattern)
require.Equal(t, PermissionRead, benGrants[0].Allow) require.Equal(t, PermissionRead, benGrants[0].Permission)
require.Equal(t, "stats", benGrants[1].TopicPattern) require.Equal(t, "stats", benGrants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, benGrants[1].Allow) require.Equal(t, PermissionReadWrite, benGrants[1].Permission)
require.Equal(t, "u_everyone", everyone.ID) require.Equal(t, "u_everyone", everyone.ID)
require.Equal(t, Everyone, everyone.Name) require.Equal(t, Everyone, everyone.Name)
require.Equal(t, RoleAnonymous, everyone.Role) require.Equal(t, RoleAnonymous, everyone.Role)
require.Equal(t, 1, len(everyoneGrants)) require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, "stats", everyoneGrants[0].TopicPattern) require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
require.Equal(t, PermissionRead, everyoneGrants[0].Allow) require.Equal(t, PermissionRead, everyoneGrants[0].Permission)
} }
func TestMigrationFrom4(t *testing.T) { func TestMigrationFrom4(t *testing.T) {
@@ -1336,7 +1587,14 @@ func newTestManager(t *testing.T, defaultAccess Permission) *Manager {
} }
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager { func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {
a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval) conf := &Config{
Filename: filename,
StartupQueries: startupQueries,
DefaultAccess: defaultAccess,
BcryptCost: bcryptCost,
QueueWriterInterval: statsWriterInterval,
}
a, err := NewManager(conf)
require.Nil(t, err) require.Nil(t, err)
return a return a
} }

View File

@@ -2,10 +2,9 @@ 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"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -14,7 +13,7 @@ import (
type User struct { type User struct {
ID string ID string
Name string Name string
Hash string // password hash (bcrypt) Hash string // Password hash (bcrypt)
Token string // Only set if token was used to log in Token string // Only set if token was used to log in
Role Role Role Role
Prefs *Prefs Prefs *Prefs
@@ -22,7 +21,8 @@ type User struct {
Stats *Stats Stats *Stats
Billing *Billing Billing *Billing
SyncTopic string SyncTopic string
Deleted bool Provisioned bool // Whether the user was provisioned by the config file
Deleted bool // Whether the user was soft-deleted
} }
// TierID returns the ID of the User.Tier, or an empty string if the user has no tier, // TierID returns the ID of the User.Tier, or an empty string if the user has no tier,
@@ -63,6 +63,7 @@ type Token struct {
LastAccess time.Time LastAccess time.Time
LastOrigin netip.Addr LastOrigin netip.Addr
Expires time.Time Expires time.Time
Provisioned bool
} }
// TokenUpdate holds information about the last access time and origin IP address of a token // TokenUpdate holds information about the last access time and origin IP address of a token
@@ -139,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
} }
@@ -148,7 +149,8 @@ type Billing struct {
// Grant is a struct that represents an access control entry to a topic by a user // Grant is a struct that represents an access control entry to a topic by a user
type Grant struct { type Grant struct {
TopicPattern string // May include wildcard (*) TopicPattern string // May include wildcard (*)
Allow Permission Permission Permission
Provisioned bool // Whether the grant was provisioned by the config file
} }
// Reservation is a struct that represents the ownership over a topic by a user // Reservation is a struct that represents the ownership over a topic by a user
@@ -240,38 +242,6 @@ const (
everyoneID = "u_everyone" everyoneID = "u_everyone"
) )
var (
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
)
// AllowedRole returns true if the given role can be used for new users
func AllowedRole(role Role) bool {
return role == RoleUser || role == RoleAdmin
}
// AllowedUsername returns true if the given username is valid
func AllowedUsername(username string) bool {
return allowedUsernameRegex.MatchString(username)
}
// AllowedTopic returns true if the given topic name is valid
func AllowedTopic(topic string) bool {
return allowedTopicRegex.MatchString(topic)
}
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
func AllowedTopicPattern(topic string) bool {
return allowedTopicPatternRegex.MatchString(topic)
}
// AllowedTier returns true if the given tier name is valid
func AllowedTier(tier string) bool {
return allowedTierRegex.MatchString(tier)
}
// Error constants used by the package // Error constants used by the package
var ( var (
ErrUnauthenticated = errors.New("unauthenticated") ErrUnauthenticated = errors.New("unauthenticated")
@@ -279,9 +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 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")
) )

79
user/util.go Normal file
View File

@@ -0,0 +1,79 @@
package user
import (
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/util"
"regexp"
"strings"
)
var (
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
allowedTokenRegex = regexp.MustCompile(`^tk_[-_A-Za-z0-9]{29}$`) // Must be tokenLength-len(tokenPrefix)
)
// AllowedRole returns true if the given role can be used for new users
func AllowedRole(role Role) bool {
return role == RoleUser || role == RoleAdmin
}
// AllowedUsername returns true if the given username is valid
func AllowedUsername(username string) bool {
return allowedUsernameRegex.MatchString(username)
}
// AllowedTopic returns true if the given topic name is valid
func AllowedTopic(topic string) bool {
return allowedTopicRegex.MatchString(topic)
}
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
func AllowedTopicPattern(topic string) bool {
return allowedTopicPatternRegex.MatchString(topic)
}
// AllowedTier returns true if the given tier name is valid
func AllowedTier(tier string) bool {
return allowedTierRegex.MatchString(tier)
}
// ValidPasswordHash checks if the given password hash is a valid bcrypt hash
func ValidPasswordHash(hash string, minCost int) error {
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
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
}
// ValidToken returns true if the given token matches the naming convention
func ValidToken(token string) bool {
return allowedTokenRegex.MatchString(token)
}
// GenerateToken generates a new token with a prefix and a fixed length
// Lowercase only to support "<topic>+<token>@<domain>" email addresses
func GenerateToken() string {
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
}
// HashPassword hashes the given password using bcrypt with the configured cost
func HashPassword(password string) (string, error) {
return hashPassword(password, DefaultUserPasswordBcryptCost)
}
func hashPassword(password string, cost int) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
return "", err
}
return string(hash), nil
}

View File

@@ -120,6 +120,18 @@ func Filter[T any](slice []T, f func(T) bool) []T {
return result return result
} }
// Find returns the first element in the slice that satisfies the given function, and a boolean indicating
// whether such an element was found. If no element is found, it returns the zero value of T and false.
func Find[T any](slice []T, f func(T) bool) (T, bool) {
for _, v := range slice {
if f(v) {
return v, true
}
}
var zero T
return zero, false
}
// RandomString returns a random string with a given length // RandomString returns a random string with a given length
func RandomString(length int) string { func RandomString(length int) string {
return RandomStringPrefix("", length) return RandomStringPrefix("", length)

258
web/package-lock.json generated
View File

@@ -395,14 +395,14 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.27.6", "version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz",
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.27.2", "@babel/template": "^7.27.2",
"@babel/types": "^7.27.6" "@babel/types": "^7.28.2"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1558,9 +1558,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.27.6", "version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1599,9 +1599,9 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.28.1", "version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.27.1",
@@ -2731,9 +2731,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz",
"integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2745,9 +2745,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz",
"integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2759,9 +2759,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz",
"integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2773,9 +2773,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz",
"integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2787,9 +2787,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz",
"integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2801,9 +2801,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz",
"integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2815,9 +2815,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz",
"integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2829,9 +2829,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz",
"integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2843,9 +2843,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz",
"integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2857,9 +2857,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz",
"integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2871,9 +2871,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loongarch64-gnu": { "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz",
"integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -2884,10 +2884,10 @@
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz",
"integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -2899,9 +2899,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz",
"integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2913,9 +2913,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz",
"integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2927,9 +2927,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz",
"integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -2941,9 +2941,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz",
"integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2955,9 +2955,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz",
"integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2969,9 +2969,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz",
"integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2983,9 +2983,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz",
"integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -2997,9 +2997,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz",
"integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -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": {
@@ -3111,9 +3111,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.8", "version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -3675,9 +3675,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001727", "version": "1.0.30001731",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -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.187", "version": "1.5.195",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.195.tgz",
"integrity": "sha512-cl5Jc9I0KGUoOoSbxvTywTa40uspGJt/BDBoDLoxJRSBpWh4FFXBsjNRHfQrONsV/OoEjDfHUmZQa2d6Ze4YgA==", "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"
@@ -7020,24 +7019,24 @@
} }
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.1.0", "version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.1.0" "react": "^19.1.1"
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
@@ -7075,9 +7074,9 @@
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.1.0", "version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
@@ -7383,9 +7382,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.45.1", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7399,26 +7398,26 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.45.1", "@rollup/rollup-android-arm-eabi": "4.46.2",
"@rollup/rollup-android-arm64": "4.45.1", "@rollup/rollup-android-arm64": "4.46.2",
"@rollup/rollup-darwin-arm64": "4.45.1", "@rollup/rollup-darwin-arm64": "4.46.2",
"@rollup/rollup-darwin-x64": "4.45.1", "@rollup/rollup-darwin-x64": "4.46.2",
"@rollup/rollup-freebsd-arm64": "4.45.1", "@rollup/rollup-freebsd-arm64": "4.46.2",
"@rollup/rollup-freebsd-x64": "4.45.1", "@rollup/rollup-freebsd-x64": "4.46.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.45.1", "@rollup/rollup-linux-arm-gnueabihf": "4.46.2",
"@rollup/rollup-linux-arm-musleabihf": "4.45.1", "@rollup/rollup-linux-arm-musleabihf": "4.46.2",
"@rollup/rollup-linux-arm64-gnu": "4.45.1", "@rollup/rollup-linux-arm64-gnu": "4.46.2",
"@rollup/rollup-linux-arm64-musl": "4.45.1", "@rollup/rollup-linux-arm64-musl": "4.46.2",
"@rollup/rollup-linux-loongarch64-gnu": "4.45.1", "@rollup/rollup-linux-loongarch64-gnu": "4.46.2",
"@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", "@rollup/rollup-linux-ppc64-gnu": "4.46.2",
"@rollup/rollup-linux-riscv64-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-gnu": "4.46.2",
"@rollup/rollup-linux-riscv64-musl": "4.45.1", "@rollup/rollup-linux-riscv64-musl": "4.46.2",
"@rollup/rollup-linux-s390x-gnu": "4.45.1", "@rollup/rollup-linux-s390x-gnu": "4.46.2",
"@rollup/rollup-linux-x64-gnu": "4.45.1", "@rollup/rollup-linux-x64-gnu": "4.46.2",
"@rollup/rollup-linux-x64-musl": "4.45.1", "@rollup/rollup-linux-x64-musl": "4.46.2",
"@rollup/rollup-win32-arm64-msvc": "4.45.1", "@rollup/rollup-win32-arm64-msvc": "4.46.2",
"@rollup/rollup-win32-ia32-msvc": "4.45.1", "@rollup/rollup-win32-ia32-msvc": "4.46.2",
"@rollup/rollup-win32-x64-msvc": "4.45.1", "@rollup/rollup-win32-x64-msvc": "4.46.2",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@@ -8625,9 +8624,9 @@
} }
}, },
"node_modules/vite-plugin-pwa": { "node_modules/vite-plugin-pwa": {
"version": "1.0.1", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.0.1.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.0.2.tgz",
"integrity": "sha512-STyUomQbydj7vGamtgQYIJI0YsUZ3T4pJLGBQDQPhzMse6aGSncmEN21OV35PrFsmCvmtiH+Nu1JS1ke4RqBjQ==", "integrity": "sha512-O3UwjsCnoDclgJANoOgzzqW7SFgwXE/th2OmUP/ILxHKwzWxxKDBu+B/Xa9Cv4IgSVSnj2HgRVIJ7F15+vQFkA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9033,6 +9032,7 @@
"version": "0.8.0-beta.0", "version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
"integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
"deprecated": "The work that was done in this beta branch won't be included in future versions",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {

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

@@ -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) {
@@ -139,7 +142,7 @@ export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-");
export const formatShortDateTime = (timestamp, language) => export const formatShortDateTime = (timestamp, language) =>
new Intl.DateTimeFormat(getKebabCaseLangStr(language), { new Intl.DateTimeFormat(getKebabCaseLangStr(language), {
dateStyle: "short", dateStyle: "short",
timeStyle: "short", timeStyle: "short"
}).format(new Date(timestamp * 1000)); }).format(new Date(timestamp * 1000));
export const formatShortDate = (timestamp, language) => export const formatShortDate = (timestamp, language) =>
@@ -178,32 +181,32 @@ export const openUrl = (url) => {
export const sounds = { export const sounds = {
ding: { ding: {
file: ding, file: ding,
label: "Ding", label: "Ding"
}, },
juntos: { juntos: {
file: juntos, file: juntos,
label: "Juntos", label: "Juntos"
}, },
pristine: { pristine: {
file: pristine, file: pristine,
label: "Pristine", label: "Pristine"
}, },
dadum: { dadum: {
file: dadum, file: dadum,
label: "Dadum", label: "Dadum"
}, },
pop: { pop: {
file: pop, file: pop,
label: "Pop", label: "Pop"
}, },
"pop-swoosh": { "pop-swoosh": {
file: popSwoosh, file: popSwoosh,
label: "Pop swoosh", label: "Pop swoosh"
}, },
beep: { beep: {
file: beep, file: beep,
label: "Beep", label: "Beep"
}, }
}; };
export const playSound = async (id) => { export const playSound = async (id) => {
@@ -216,7 +219,7 @@ export const playSound = async (id) => {
export async function* fetchLinesIterator(fileURL, headers) { export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder("utf-8"); const utf8Decoder = new TextDecoder("utf-8");
const response = await fetch(fileURL, { const response = await fetch(fileURL, {
headers, headers
}); });
const reader = response.body.getReader(); const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read(); let { value: chunk, done: readerDone } = await reader.read();
@@ -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);
} else {
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

@@ -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,10 @@ 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 +242,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;