Compare commits
74 Commits
template-d
...
http-clipb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb386af58 | ||
|
|
b105ed6727 | ||
|
|
965110b2c3 | ||
|
|
c8ac104043 | ||
|
|
e39498702d | ||
|
|
9b97067b10 | ||
|
|
5244e0be14 | ||
|
|
6eb25f68ac | ||
|
|
efe7c3fa70 | ||
|
|
ce4b2ae9a0 | ||
|
|
ba86e08ffe | ||
|
|
2d9e2356b1 | ||
|
|
fe5c844a21 | ||
|
|
97410db301 | ||
|
|
887751cd5d | ||
|
|
044326068c | ||
|
|
57a51ab2da | ||
|
|
998dbd9054 | ||
|
|
a5a55bd43a | ||
|
|
00409d834b | ||
|
|
d9ab7cc78d | ||
|
|
99a2ca8802 | ||
|
|
ea338ae4fa | ||
|
|
32fa8d43c1 | ||
|
|
0f166e0a1d | ||
|
|
46e423fc40 | ||
|
|
eac523dcf9 | ||
|
|
4225ce2f42 | ||
|
|
d35dfc14d1 | ||
|
|
cef228f880 | ||
|
|
bcfb50b35a | ||
|
|
c4c4916bc8 | ||
|
|
81463614c9 | ||
|
|
15a7f86344 | ||
|
|
3c1da90f47 | ||
|
|
27151d1cac | ||
|
|
a1c6dd2085 | ||
|
|
8f930acfb8 | ||
|
|
08d44703c3 | ||
|
|
82282419fe | ||
|
|
e290d1307f | ||
|
|
747c5c9fff | ||
|
|
9f987e66fa | ||
|
|
b91ff5f0b5 | ||
|
|
23ec7702fc | ||
|
|
f8082d9481 | ||
|
|
d9ecee7200 | ||
|
|
149c13e9d8 | ||
|
|
07e9670a09 | ||
|
|
0e67228605 | ||
|
|
2578236d8d | ||
|
|
fe545423c5 | ||
|
|
f3c67f1d71 | ||
|
|
27b3a89247 | ||
|
|
1470afb715 | ||
|
|
b495a744c9 | ||
|
|
d2b5917e2b | ||
|
|
52ca98611c | ||
|
|
1b394e9bb8 | ||
|
|
0d36ab8af3 | ||
|
|
141ddb3a51 | ||
|
|
f99801a2e6 | ||
|
|
4457e9e26f | ||
|
|
4eb7dc563c | ||
|
|
269373d75d | ||
|
|
ef275ac0c1 | ||
|
|
f59df0f40a | ||
|
|
214f70e62f | ||
|
|
51af114b2e | ||
|
|
83bf9d4d6c | ||
|
|
006f73af7d | ||
|
|
c0b5151bae | ||
|
|
efef587671 | ||
|
|
5ccc131e73 |
2
Makefile
2
Makefile
@@ -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
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
140
cmd/serve.go
140
cmd/serve.go
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
32
cmd/tier.go
32
cmd/tier.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
41
cmd/token.go
41
cmd/token.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
62
cmd/user.go
62
cmd/user.go
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
202
docs/config.md
202
docs/config.md
@@ -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)"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
22
go.mod
@@ -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
75
go.sum
@@ -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
21
payments/payments.go
Normal 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
|
||||||
|
}
|
||||||
18
payments/payments_dummy.go
Normal file
18
payments/payments_dummy.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
38
server/server_firebase_dummy.go
Normal file
38
server/server_firebase_dummy.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nofirebase
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
47
server/server_payments_dummy.go
Normal file
47
server/server_payments_dummy.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
29
server/server_webpush_dummy.go
Normal file
29
server/server_webpush_dummy.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
596
user/manager.go
596
user/manager.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
79
user/util.go
Normal 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
|
||||||
|
}
|
||||||
12
util/util.go
12
util/util.go
@@ -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
258
web/package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user