Compare commits
34 Commits
busy-timeo
...
v2.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac523dcf9 | ||
|
|
4225ce2f42 | ||
|
|
d35dfc14d1 | ||
|
|
cef228f880 | ||
|
|
bcfb50b35a | ||
|
|
c4c4916bc8 | ||
|
|
81463614c9 | ||
|
|
15a7f86344 | ||
|
|
3c1da90f47 | ||
|
|
27151d1cac | ||
|
|
a1c6dd2085 | ||
|
|
8f930acfb8 | ||
|
|
08d44703c3 | ||
|
|
82282419fe | ||
|
|
e290d1307f | ||
|
|
747c5c9fff | ||
|
|
9f987e66fa | ||
|
|
b91ff5f0b5 | ||
|
|
23ec7702fc | ||
|
|
149c13e9d8 | ||
|
|
07e9670a09 | ||
|
|
0e67228605 | ||
|
|
2578236d8d | ||
|
|
fe545423c5 | ||
|
|
f3c67f1d71 | ||
|
|
27b3a89247 | ||
|
|
1470afb715 | ||
|
|
b495a744c9 | ||
|
|
d2b5917e2b | ||
|
|
52ca98611c | ||
|
|
1b394e9bb8 | ||
|
|
0d36ab8af3 | ||
|
|
269373d75d | ||
|
|
ef275ac0c1 |
@@ -116,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)
|
||||||
}
|
}
|
||||||
@@ -140,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,40 +197,40 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
|
|||||||
}
|
}
|
||||||
provisioned := ""
|
provisioned := ""
|
||||||
if u.Provisioned {
|
if u.Provisioned {
|
||||||
provisioned = ", provisioned user"
|
provisioned = ", server config"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned)
|
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 {
|
||||||
grantProvisioned := ""
|
grantProvisioned := ""
|
||||||
if grant.Provisioned {
|
if grant.Provisioned {
|
||||||
grantProvisioned = ", provisioned access entry"
|
grantProvisioned = " (server config)"
|
||||||
}
|
}
|
||||||
if grant.Permission.IsReadWrite() {
|
if grant.Permission.IsReadWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
fmt.Fprintf(c.App.Writer, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||||
} else if grant.Permission.IsRead() {
|
} else if grant.Permission.IsRead() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
fmt.Fprintf(c.App.Writer, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||||
} else if grant.Permission.IsWrite() {
|
} else if grant.Permission.IsWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
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%s\n", grant.TopicPattern, grantProvisioned)
|
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()
|
||||||
|
|||||||
97
cmd/serve.go
97
cmd/serve.go
@@ -48,8 +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-provision-users", Aliases: []string{"auth_provision_users"}, EnvVars: []string{"NTFY_AUTH_PROVISION_USERS"}, Usage: "pre-provisioned declarative users"}),
|
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-provision-access", Aliases: []string{"auth_provision_access"}, EnvVars: []string{"NTFY_AUTH_PROVISION_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
|
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)"}),
|
||||||
@@ -156,8 +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")
|
||||||
authProvisionUsersRaw := c.StringSlice("auth-provision-users")
|
authUsersRaw := c.StringSlice("auth-users")
|
||||||
authProvisionAccessRaw := c.StringSlice("auth-provision-access")
|
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")
|
||||||
@@ -353,11 +355,15 @@ func execServe(c *cli.Context) error {
|
|||||||
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'")
|
||||||
}
|
}
|
||||||
authProvisionUsers, err := parseProvisionUsers(authProvisionUsersRaw)
|
authUsers, err := parseUsers(authUsersRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw)
|
authAccess, err := parseAccess(authUsers, authAccessRaw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authTokens, err := parseTokens(authUsers, authTokensRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -416,8 +422,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.AuthProvisionedUsers = authProvisionUsers
|
conf.AuthUsers = authUsers
|
||||||
conf.AuthProvisionedAccess = authProvisionAccess
|
conf.AuthAccess = authAccess
|
||||||
|
conf.AuthTokens = authTokens
|
||||||
conf.AttachmentCacheDir = attachmentCacheDir
|
conf.AttachmentCacheDir = attachmentCacheDir
|
||||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
@@ -531,63 +538,63 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseProvisionUsers(usersRaw []string) ([]*user.User, error) {
|
func parseUsers(usersRaw []string) ([]*user.User, error) {
|
||||||
provisionUsers := make([]*user.User, 0)
|
users := make([]*user.User, 0)
|
||||||
for _, userLine := range usersRaw {
|
for _, userLine := range usersRaw {
|
||||||
parts := strings.Split(userLine, ":")
|
parts := strings.Split(userLine, ":")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-users: %s, expected format: 'name:hash:role'", userLine)
|
return nil, fmt.Errorf("invalid auth-users: %s, expected format: 'name:hash:role'", userLine)
|
||||||
}
|
}
|
||||||
username := strings.TrimSpace(parts[0])
|
username := strings.TrimSpace(parts[0])
|
||||||
passwordHash := strings.TrimSpace(parts[1])
|
passwordHash := strings.TrimSpace(parts[1])
|
||||||
role := user.Role(strings.TrimSpace(parts[2]))
|
role := user.Role(strings.TrimSpace(parts[2]))
|
||||||
if !user.AllowedUsername(username) {
|
if !user.AllowedUsername(username) {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine)
|
return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine)
|
||||||
} else if err := user.AllowedPasswordHash(passwordHash); err != nil {
|
} else if err := user.ValidPasswordHash(passwordHash); err != nil {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-users: %s, %s", userLine, err.Error())
|
return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error())
|
||||||
} else if !user.AllowedRole(role) {
|
} else if !user.AllowedRole(role) {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
|
return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
|
||||||
}
|
}
|
||||||
provisionUsers = append(provisionUsers, &user.User{
|
users = append(users, &user.User{
|
||||||
Name: username,
|
Name: username,
|
||||||
Hash: passwordHash,
|
Hash: passwordHash,
|
||||||
Role: role,
|
Role: role,
|
||||||
Provisioned: true,
|
Provisioned: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return provisionUsers, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) {
|
func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) {
|
||||||
access := make(map[string][]*user.Grant)
|
access := make(map[string][]*user.Grant)
|
||||||
for _, accessLine := range provisionAccessRaw {
|
for _, accessLine := range accessRaw {
|
||||||
parts := strings.Split(accessLine, ":")
|
parts := strings.Split(accessLine, ":")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-access: %s, expected format: 'user:topic:permission'", accessLine)
|
return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine)
|
||||||
}
|
}
|
||||||
username := strings.TrimSpace(parts[0])
|
username := strings.TrimSpace(parts[0])
|
||||||
if username == userEveryone {
|
if username == userEveryone {
|
||||||
username = user.Everyone
|
username = user.Everyone
|
||||||
}
|
}
|
||||||
provisionUser, exists := util.Find(provisionUsers, func(u *user.User) bool {
|
u, exists := util.Find(users, func(u *user.User) bool {
|
||||||
return u.Name == username
|
return u.Name == username
|
||||||
})
|
})
|
||||||
if username != user.Everyone {
|
if username != user.Everyone {
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not provisioned", accessLine, username)
|
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username)
|
||||||
} else if !user.AllowedUsername(username) {
|
} else if !user.AllowedUsername(username) {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-access: %s, username %s invalid", accessLine, username)
|
return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username)
|
||||||
} else if provisionUser.Role != user.RoleUser {
|
} else if u.Role != user.RoleUser {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username)
|
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])
|
topic := strings.TrimSpace(parts[1])
|
||||||
if !user.AllowedTopicPattern(topic) {
|
if !user.AllowedTopicPattern(topic) {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-access: %s, topic pattern %s invalid", accessLine, topic)
|
return nil, fmt.Errorf("invalid auth-access: %s, topic pattern %s invalid", accessLine, topic)
|
||||||
}
|
}
|
||||||
permission, err := user.ParsePermission(strings.TrimSpace(parts[2]))
|
permission, err := user.ParsePermission(strings.TrimSpace(parts[2]))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid auth-provision-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error())
|
return nil, fmt.Errorf("invalid auth-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error())
|
||||||
}
|
}
|
||||||
if _, exists := access[username]; !exists {
|
if _, exists := access[username]; !exists {
|
||||||
access[username] = make([]*user.Grant, 0)
|
access[username] = make([]*user.Grant, 0)
|
||||||
@@ -601,6 +608,42 @@ func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []stri
|
|||||||
return access, nil
|
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$abcdefghijklmnopqrstuvwxyz:user"},
|
||||||
|
expected: []*user.User{
|
||||||
|
{
|
||||||
|
Name: "alice",
|
||||||
|
Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz",
|
||||||
|
Role: user.RoleUser,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple users with different roles",
|
||||||
|
input: []string{
|
||||||
|
"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user",
|
||||||
|
"bob:$2b$10$abcdefghijklmnopqrstuvwxyz:admin",
|
||||||
|
},
|
||||||
|
expected: []*user.User{
|
||||||
|
{
|
||||||
|
Name: "alice",
|
||||||
|
Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz",
|
||||||
|
Role: user.RoleUser,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bob",
|
||||||
|
Hash: "$2b$10$abcdefghijklmnopqrstuvwxyz",
|
||||||
|
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:$2y$10$abcdefghijklmnopqrstuvwxyz:user"},
|
||||||
|
expected: []*user.User{
|
||||||
|
{
|
||||||
|
Name: "alice.test+123@example.com",
|
||||||
|
Hash: "$2y$10$abcdefghijklmnopqrstuvwxyz",
|
||||||
|
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$abcdefghijklmnopqrstuvwxyz:user"},
|
||||||
|
error: "invalid auth-users: alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid password hash - wrong prefix",
|
||||||
|
input: []string{"alice:plaintext:user"},
|
||||||
|
error: "invalid auth-users: alice:plaintext:user, password hash but be a bcrypt hash, use 'ntfy user hash' to generate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid role",
|
||||||
|
input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid"},
|
||||||
|
error: "invalid auth-users: alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty username",
|
||||||
|
input: []string{":$2a$10$abcdefghijklmnopqrstuvwxyz:user"},
|
||||||
|
error: "invalid auth-users: :$2a$10$abcdefghijklmnopqrstuvwxyz: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 {
|
||||||
|
|||||||
26
cmd/user.go
26
cmd/user.go
@@ -143,7 +143,7 @@ Example:
|
|||||||
Description: `Asks for a password and creates a bcrypt password hash.
|
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
|
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-provision-users.
|
for predefined users in the server config file, in auth-users.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
$ ntfy user hash
|
$ ntfy user hash
|
||||||
@@ -211,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)
|
||||||
@@ -226,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ func execUserDel(c *cli.Context) error {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,24 +301,20 @@ func execUserChangeRole(c *cli.Context) error {
|
|||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func execUserHash(c *cli.Context) error {
|
func execUserHash(c *cli.Context) error {
|
||||||
manager, err := createUserManager(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
password, err := readPasswordAndConfirm(c)
|
password, err := readPasswordAndConfirm(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hash, err := manager.HashPassword(password)
|
hash, err := user.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to hash password: %w", err)
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.Writer, "%s\n", string(hash))
|
fmt.Fprintln(c.App.Writer, hash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,12 +339,12 @@ func execUserChangeTier(c *cli.Context) error {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -382,7 +378,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
|||||||
Filename: authFile,
|
Filename: authFile,
|
||||||
StartupQueries: authStartupQueries,
|
StartupQueries: authStartupQueries,
|
||||||
DefaultAccess: authDefault,
|
DefaultAccess: authDefault,
|
||||||
ProvisionEnabled: false, // Do not re-provision users on manager initialization
|
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
|
||||||
BcryptCost: user.DefaultUserPasswordBcryptCost,
|
BcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||||
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
204
docs/config.md
204
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'
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
|
### ntfy server v2.14.0
|
||||||
|
Released August 5, 2025
|
||||||
|
|
||||||
|
This release adds support for [declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config). This allows you to define users, ACL entries and tokens in the config file, which is useful for static deployments or deployments that use a configuration management system.
|
||||||
|
|
||||||
|
It also adds support for [pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support, as well as advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) functions.
|
||||||
|
|
||||||
|
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy
|
||||||
|
will always remain open source.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), [#1413](https://github.com/binwiederhier/ntfy/pull/1413), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing and implementing parts of it)
|
||||||
|
* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))
|
||||||
|
* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work)
|
||||||
|
|
||||||
### ntfy server v2.13.0
|
### ntfy server v2.13.0
|
||||||
Released July 10, 2025
|
Released July 10, 2025
|
||||||
|
|
||||||
@@ -1452,13 +1468,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
### ntfy server v2.14.0 (UNRELEASED)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* 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))
|
|
||||||
* 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 Android app v1.16.1 (UNRELEASED)
|
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|||||||
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
|
||||||
)
|
)
|
||||||
|
|||||||
44
go.sum
44
go.sum
@@ -18,12 +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.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE=
|
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||||
firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
|
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
@@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
|
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -81,8 +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.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
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=
|
||||||
@@ -112,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=
|
||||||
@@ -127,8 +127,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||||
@@ -261,18 +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.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
|
google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
|
||||||
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
|
||||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
|
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b h1:eZTgydvqZO44zyTZAvMaSyAxccZZdraiSAGvqOczVvk=
|
||||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
|
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:suyz2QBHQKlGIF92HEEsCfO1SwxXdk7PFLz+Zd9Uah4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
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=
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ type Config struct {
|
|||||||
AuthFile string
|
AuthFile string
|
||||||
AuthStartupQueries string
|
AuthStartupQueries string
|
||||||
AuthDefault user.Permission
|
AuthDefault user.Permission
|
||||||
AuthProvisionedUsers []*user.User
|
AuthUsers []*user.User
|
||||||
AuthProvisionedAccess map[string][]*user.Grant
|
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}
|
||||||
|
|||||||
@@ -201,8 +201,9 @@ func New(conf *Config) (*Server, error) {
|
|||||||
StartupQueries: conf.AuthStartupQueries,
|
StartupQueries: conf.AuthStartupQueries,
|
||||||
DefaultAccess: conf.AuthDefault,
|
DefaultAccess: conf.AuthDefault,
|
||||||
ProvisionEnabled: true, // Enable provisioning of users and access
|
ProvisionEnabled: true, // Enable provisioning of users and access
|
||||||
ProvisionUsers: conf.AuthProvisionedUsers,
|
Users: conf.AuthUsers,
|
||||||
ProvisionAccess: conf.AuthProvisionedAccess,
|
Access: conf.AuthAccess,
|
||||||
|
Tokens: conf.AuthTokens,
|
||||||
BcryptCost: conf.AuthBcryptCost,
|
BcryptCost: conf.AuthBcryptCost,
|
||||||
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
||||||
}
|
}
|
||||||
@@ -1001,7 +1002,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
|
||||||
}
|
}
|
||||||
@@ -1043,7 +1049,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" {
|
||||||
@@ -1129,8 +1134,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 {
|
||||||
@@ -1208,7 +1213,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,10 +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-provision-users is a list of users that are automatically created when the server starts.
|
# - auth-users is a list of users that are automatically created when the server starts.
|
||||||
# Each entry is in the format "<username>:<bcrypt-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
|
# Each entry is in the format "<username>:<password-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
|
||||||
# - auth-provision-access is a list of access control entries that are automatically created when the server starts.
|
# 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".
|
# 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
|
||||||
@@ -98,8 +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-provision-users:
|
# auth-users:
|
||||||
# auth-provision-access:
|
# 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
|
||||||
@@ -139,11 +140,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
|||||||
lastOrigin = t.LastOrigin.String()
|
lastOrigin = t.LastOrigin.String()
|
||||||
}
|
}
|
||||||
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
|
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
|
||||||
Token: t.Value,
|
Token: t.Value,
|
||||||
Label: t.Label,
|
Label: t.Label,
|
||||||
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) {
|
||||||
|
|||||||
@@ -3069,6 +3069,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
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -333,11 +360,12 @@ type apiAccountTokenUpdateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountTokenResponse struct {
|
type apiAccountTokenResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
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"`
|
||||||
|
|||||||
418
user/manager.go
418
user/manager.go
@@ -13,6 +13,7 @@ import (
|
|||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -111,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,
|
||||||
@@ -181,15 +184,17 @@ const (
|
|||||||
ELSE 2
|
ELSE 2
|
||||||
END, user
|
END, user
|
||||||
`
|
`
|
||||||
selectUserCountQuery = `SELECT COUNT(*) FROM user`
|
selectUserCountQuery = `SELECT COUNT(*) FROM user`
|
||||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
selectUserIDFromUsernameQuery = `SELECT id FROM user WHERE user = ?`
|
||||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
|
updateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?`
|
||||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
|
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||||
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
|
||||||
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
|
||||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
||||||
|
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
||||||
|
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||||
|
|
||||||
upsertUserAccessQuery = `
|
upsertUserAccessQuery = `
|
||||||
INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)
|
INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)
|
||||||
@@ -219,7 +224,7 @@ const (
|
|||||||
selectUserReservationsCountQuery = `
|
selectUserReservationsCountQuery = `
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM user_access
|
FROM user_access
|
||||||
WHERE user_id = owner_user_id
|
WHERE user_id = owner_user_id
|
||||||
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||||
`
|
`
|
||||||
selectUserReservationsOwnerQuery = `
|
selectUserReservationsOwnerQuery = `
|
||||||
@@ -254,17 +259,24 @@ const (
|
|||||||
AND topic = ?
|
AND topic = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
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`
|
||||||
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
upsertTokenQuery = `
|
||||||
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
|
INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned)
|
||||||
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
ON CONFLICT (user_id, token)
|
||||||
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned;
|
||||||
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
`
|
||||||
deleteExcessTokensQuery = `
|
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? 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 = ?`
|
||||||
|
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 = ?`
|
||||||
|
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
||||||
|
deleteExcessTokensQuery = `
|
||||||
DELETE FROM user_token
|
DELETE FROM user_token
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
AND (user_id, token) NOT IN (
|
AND (user_id, token) NOT IN (
|
||||||
@@ -469,7 +481,7 @@ const (
|
|||||||
role,
|
role,
|
||||||
prefs,
|
prefs,
|
||||||
sync_topic,
|
sync_topic,
|
||||||
0,
|
0, -- provisioned
|
||||||
stats_messages,
|
stats_messages,
|
||||||
stats_emails,
|
stats_emails,
|
||||||
stats_calls,
|
stats_calls,
|
||||||
@@ -479,7 +491,8 @@ const (
|
|||||||
stripe_subscription_interval,
|
stripe_subscription_interval,
|
||||||
stripe_subscription_paid_until,
|
stripe_subscription_paid_until,
|
||||||
stripe_subscription_cancel_at,
|
stripe_subscription_cancel_at,
|
||||||
created, deleted
|
created,
|
||||||
|
deleted
|
||||||
FROM user_old;
|
FROM user_old;
|
||||||
DROP TABLE user_old;
|
DROP TABLE user_old;
|
||||||
|
|
||||||
@@ -499,10 +512,27 @@ const (
|
|||||||
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
|
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
|
||||||
DROP TABLE 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
|
-- Recreate indices
|
||||||
CREATE UNIQUE INDEX idx_user ON user (user);
|
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_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_stripe_subscription_id ON user (stripe_subscription_id);
|
||||||
|
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
|
||||||
|
|
||||||
-- Re-enable foreign keys
|
-- Re-enable foreign keys
|
||||||
PRAGMA foreign_keys=on;
|
PRAGMA foreign_keys=on;
|
||||||
@@ -534,9 +564,10 @@ type Config struct {
|
|||||||
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db"
|
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
|
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers
|
||||||
DefaultAccess Permission // Default permission if no ACL matches
|
DefaultAccess Permission // Default permission if no ACL matches
|
||||||
ProvisionEnabled bool // Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
||||||
ProvisionUsers []*User // Predefined users to create on startup
|
Users []*User // Predefined users to create on startup
|
||||||
ProvisionAccess map[string][]*Grant // Predefined access grants 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
|
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
|
BcryptCost int // Cost of generated passwords; lowering makes testing faster
|
||||||
}
|
}
|
||||||
@@ -574,7 +605,7 @@ func NewManager(config *Config) (*Manager, error) {
|
|||||||
statsQueue: make(map[string]*Stats),
|
statsQueue: make(map[string]*Stats),
|
||||||
tokenQueue: make(map[string]*TokenUpdate),
|
tokenQueue: make(map[string]*TokenUpdate),
|
||||||
}
|
}
|
||||||
if err := manager.maybeProvisionUsersAndAccess(); err != nil {
|
if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
go manager.asyncQueueWriter(config.QueueWriterInterval)
|
go manager.asyncQueueWriter(config.QueueWriterInterval)
|
||||||
@@ -622,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)
|
||||||
@@ -652,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +713,25 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
|||||||
return tokens, nil
|
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
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokens = append(tokens, token)
|
||||||
|
}
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Token returns a specific token for a user
|
// Token returns a specific token for a user
|
||||||
func (a *Manager) Token(userID, token string) (*Token, error) {
|
func (a *Manager) Token(userID, token string) (*Token, error) {
|
||||||
rows, err := a.db.Query(selectTokenQuery, userID, token)
|
rows, err := a.db.Query(selectTokenQuery, userID, token)
|
||||||
@@ -697,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
|
||||||
@@ -710,11 +759,12 @@ func (a *Manager) readToken(rows *sql.Rows) (*Token, error) {
|
|||||||
lastOriginIP = netip.IPv4Unspecified()
|
lastOriginIP = netip.IPv4Unspecified()
|
||||||
}
|
}
|
||||||
return &Token{
|
return &Token{
|
||||||
Value: token,
|
Value: token,
|
||||||
Label: label,
|
Label: label,
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,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
|
||||||
@@ -746,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 {
|
||||||
@@ -773,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
|
||||||
@@ -923,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 {
|
||||||
@@ -986,11 +1066,11 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha
|
|||||||
var err error = nil
|
var err error = nil
|
||||||
if hashed {
|
if hashed {
|
||||||
hash = password
|
hash = password
|
||||||
if err := AllowedPasswordHash(hash); err != nil {
|
if err := ValidPasswordHash(hash); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hash, err = a.HashPassword(password)
|
hash, err = hashPassword(password, a.config.BcryptCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1009,6 +1089,9 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha
|
|||||||
// RemoveUser deletes the user with the given username. The function returns nil on success, even
|
// RemoveUser deletes the user with the given username. The function returns nil on success, even
|
||||||
// if the user did not exist in the first place.
|
// if the user did not exist in the first place.
|
||||||
func (a *Manager) RemoveUser(username string) error {
|
func (a *Manager) RemoveUser(username string) error {
|
||||||
|
if err := a.CanChangeUser(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return execTx(a.db, func(tx *sql.Tx) error {
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
return a.removeUserTx(tx, username)
|
return a.removeUserTx(tx, username)
|
||||||
})
|
})
|
||||||
@@ -1326,21 +1409,36 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
|
|||||||
|
|
||||||
// ChangePassword changes a user's password
|
// ChangePassword changes a user's password
|
||||||
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
|
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
|
||||||
|
if err := a.CanChangeUser(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return execTx(a.db, func(tx *sql.Tx) error {
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
return a.changePasswordTx(tx, username, password, hashed)
|
return a.changePasswordTx(tx, username, password, hashed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanChangeUser checks if the user with the given username can be changed.
|
||||||
|
// This is used to prevent changes to provisioned users, which are defined in the config file.
|
||||||
|
func (a *Manager) CanChangeUser(username string) error {
|
||||||
|
user, err := a.User(username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if user.Provisioned {
|
||||||
|
return ErrProvisionedUserChange
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
|
func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
|
||||||
var hash string
|
var hash string
|
||||||
var err error
|
var err error
|
||||||
if hashed {
|
if hashed {
|
||||||
hash = password
|
hash = password
|
||||||
if err := AllowedPasswordHash(hash); err != nil {
|
if err := ValidPasswordHash(hash); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hash, err = a.HashPassword(password)
|
hash, err = hashPassword(password, a.config.BcryptCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1354,6 +1452,9 @@ func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed
|
|||||||
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
|
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
|
||||||
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
||||||
func (a *Manager) ChangeRole(username string, role Role) error {
|
func (a *Manager) ChangeRole(username string, role Role) error {
|
||||||
|
if err := a.CanChangeUser(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return execTx(a.db, func(tx *sql.Tx) error {
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
return a.changeRoleTx(tx, username, role)
|
return a.changeRoleTx(tx, username, role)
|
||||||
})
|
})
|
||||||
@@ -1374,6 +1475,15 @@ func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error {
|
|||||||
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 {
|
||||||
@@ -1468,19 +1578,25 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string
|
|||||||
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,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
|
||||||
@@ -1647,79 +1763,150 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword hashes the given password using bcrypt with the configured cost
|
|
||||||
func (a *Manager) HashPassword(password string) (string, error) {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(hash), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the underlying database
|
// Close closes the underlying database
|
||||||
func (a *Manager) Close() error {
|
func (a *Manager) Close() error {
|
||||||
return a.db.Close()
|
return a.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Manager) maybeProvisionUsersAndAccess() error {
|
// maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config.
|
||||||
|
func (a *Manager) maybeProvisionUsersAccessAndTokens() error {
|
||||||
if !a.config.ProvisionEnabled {
|
if !a.config.ProvisionEnabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
users, err := a.Users()
|
existingUsers, err := a.Users()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
provisionUsernames := util.Map(a.config.ProvisionUsers, func(u *User) string {
|
provisionUsernames := util.Map(a.config.Users, func(u *User) string {
|
||||||
return u.Name
|
return u.Name
|
||||||
})
|
})
|
||||||
return execTx(a.db, func(tx *sql.Tx) error {
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
// Remove users that are provisioned, but not in the config anymore
|
if err := a.maybeProvisionUsers(tx, provisionUsernames, existingUsers); err != nil {
|
||||||
for _, user := range users {
|
return fmt.Errorf("failed to provision users: %v", err)
|
||||||
if user.Name == Everyone {
|
}
|
||||||
continue
|
if err := a.maybeProvisionGrants(tx); err != nil {
|
||||||
} else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) {
|
return fmt.Errorf("failed to provision grants: %v", err)
|
||||||
log.Tag(tag).Info("Removing previously provisioned user %s", user.Name)
|
}
|
||||||
if err := a.removeUserTx(tx, user.Name); err != nil {
|
if err := a.maybeProvisionTokens(tx, provisionUsernames); err != nil {
|
||||||
return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err)
|
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.ProvisionUsers {
|
// Add or update provisioned users
|
||||||
if user.Name == Everyone {
|
for _, user := range a.config.Users {
|
||||||
continue
|
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)
|
||||||
}
|
}
|
||||||
existingUser, exists := util.Find(users, func(u *User) bool {
|
} else {
|
||||||
return u.Name == user.Name
|
if !existingUser.Provisioned {
|
||||||
})
|
if err := a.changeProvisionedTx(tx, user.Name, true); err != nil {
|
||||||
if !exists {
|
return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err)
|
||||||
log.Tag(tag).Info("Adding provisioned user %s", user.Name)
|
|
||||||
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 && (existingUser.Hash != user.Hash || existingUser.Role != user.Role) {
|
}
|
||||||
log.Tag(tag).Info("Updating provisioned user %s", user.Name)
|
if existingUser.Hash != user.Hash {
|
||||||
if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil {
|
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)
|
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 {
|
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 fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove and (re-)add provisioned grants
|
}
|
||||||
if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil {
|
return nil
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
// 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 username, grants := range a.config.ProvisionAccess {
|
for _, grant := range grants {
|
||||||
for _, grant := range grants {
|
if err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil {
|
||||||
if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil {
|
return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err)
|
||||||
return err
|
}
|
||||||
}
|
if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
})
|
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,
|
||||||
@@ -1929,11 +2116,28 @@ func execTx(db *sql.DB, f func(tx *sql.Tx) error) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
if err := f(tx); err != nil {
|
if err := f(tx); err != nil {
|
||||||
if e := tx.Rollback(); e != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,7 +194,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 +241,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))
|
||||||
}
|
}
|
||||||
@@ -523,7 +523,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 +586,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 +638,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 +668,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 +682,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)
|
||||||
@@ -795,7 +795,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
|
||||||
@@ -1102,16 +1102,21 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
|
|||||||
Filename: f,
|
Filename: f,
|
||||||
DefaultAccess: PermissionReadWrite,
|
DefaultAccess: PermissionReadWrite,
|
||||||
ProvisionEnabled: true,
|
ProvisionEnabled: true,
|
||||||
ProvisionUsers: []*User{
|
Users: []*User{
|
||||||
{Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
{Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||||
{Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin},
|
{Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin},
|
||||||
},
|
},
|
||||||
ProvisionAccess: map[string][]*Grant{
|
Access: map[string][]*Grant{
|
||||||
"philuser": {
|
"philuser": {
|
||||||
{TopicPattern: "stats", Permission: PermissionReadWrite},
|
{TopicPattern: "stats", Permission: PermissionReadWrite},
|
||||||
{TopicPattern: "secret", Permission: PermissionRead},
|
{TopicPattern: "secret", Permission: PermissionRead},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Tokens: map[string][]*Token{
|
||||||
|
"philuser": {
|
||||||
|
{Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token"},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
a, err := NewManager(conf)
|
a, err := NewManager(conf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -1123,36 +1128,55 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
|
|||||||
users, err := a.Users()
|
users, err := a.Users()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Len(t, users, 4)
|
require.Len(t, users, 4)
|
||||||
|
|
||||||
require.Equal(t, "philadmin", users[0].Name)
|
require.Equal(t, "philadmin", users[0].Name)
|
||||||
require.Equal(t, RoleAdmin, users[0].Role)
|
require.Equal(t, RoleAdmin, users[0].Role)
|
||||||
|
|
||||||
require.Equal(t, "philmanual", users[1].Name)
|
require.Equal(t, "philmanual", users[1].Name)
|
||||||
require.Equal(t, RoleUser, users[1].Role)
|
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")
|
grants, err := a.Grants("philuser")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "philuser", users[2].Name)
|
|
||||||
require.Equal(t, RoleUser, users[2].Role)
|
|
||||||
require.Equal(t, 2, len(grants))
|
require.Equal(t, 2, len(grants))
|
||||||
require.Equal(t, "secret", grants[0].TopicPattern)
|
require.Equal(t, "secret", grants[0].TopicPattern)
|
||||||
require.Equal(t, PermissionRead, grants[0].Permission)
|
require.Equal(t, PermissionRead, grants[0].Permission)
|
||||||
require.Equal(t, "stats", grants[1].TopicPattern)
|
require.Equal(t, "stats", grants[1].TopicPattern)
|
||||||
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
||||||
|
|
||||||
require.Equal(t, "*", users[3].Name)
|
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)
|
// Re-open the DB (second app start)
|
||||||
require.Nil(t, a.db.Close())
|
require.Nil(t, a.db.Close())
|
||||||
conf.ProvisionUsers = []*User{
|
conf.Users = []*User{
|
||||||
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||||
}
|
}
|
||||||
conf.ProvisionAccess = map[string][]*Grant{
|
conf.Access = map[string][]*Grant{
|
||||||
"philuser": {
|
"philuser": {
|
||||||
{TopicPattern: "stats12", Permission: PermissionReadWrite},
|
{TopicPattern: "stats12", Permission: PermissionReadWrite},
|
||||||
{TopicPattern: "secret12", Permission: PermissionRead},
|
{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)
|
a, err = NewManager(conf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
@@ -1160,30 +1184,43 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
|
|||||||
users, err = a.Users()
|
users, err = a.Users()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Len(t, users, 3)
|
require.Len(t, users, 3)
|
||||||
|
|
||||||
require.Equal(t, "philmanual", users[0].Name)
|
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, RoleUser, users[0].Role)
|
||||||
|
require.Equal(t, "*", users[2].Name)
|
||||||
|
|
||||||
grants, err = a.Grants("philuser")
|
grants, err = a.Grants("philuser")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "philuser", users[1].Name)
|
|
||||||
require.Equal(t, RoleUser, users[1].Role)
|
|
||||||
require.Equal(t, 2, len(grants))
|
require.Equal(t, 2, len(grants))
|
||||||
require.Equal(t, "secret12", grants[0].TopicPattern)
|
require.Equal(t, "secret12", grants[0].TopicPattern)
|
||||||
require.Equal(t, PermissionRead, grants[0].Permission)
|
require.Equal(t, PermissionRead, grants[0].Permission)
|
||||||
require.Equal(t, "stats12", grants[1].TopicPattern)
|
require.Equal(t, "stats12", grants[1].TopicPattern)
|
||||||
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
||||||
|
|
||||||
require.Equal(t, "*", users[2].Name)
|
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)
|
// Re-open the DB again (third app start)
|
||||||
require.Nil(t, a.db.Close())
|
require.Nil(t, a.db.Close())
|
||||||
conf.ProvisionUsers = []*User{}
|
conf.Users = []*User{}
|
||||||
conf.ProvisionAccess = map[string][]*Grant{}
|
conf.Access = map[string][]*Grant{}
|
||||||
|
conf.Tokens = map[string][]*Token{}
|
||||||
a, err = NewManager(conf)
|
a, err = NewManager(conf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Check that the provisioned users are there
|
// Check that the provisioned users are all gone
|
||||||
users, err = a.Users()
|
users, err = a.Users()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Len(t, users, 2)
|
require.Len(t, users, 2)
|
||||||
@@ -1191,6 +1228,103 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
|
|||||||
require.Equal(t, "philmanual", users[0].Name)
|
require.Equal(t, "philmanual", users[0].Name)
|
||||||
require.Equal(t, RoleUser, users[0].Role)
|
require.Equal(t, RoleUser, users[0].Role)
|
||||||
require.Equal(t, "*", users[1].Name)
|
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$AAAU21sX1uhZamTLJXHuxgVC0Z/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$AAAU21sX1uhZamTLJXHuxgVC0Z/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) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -59,11 +58,12 @@ type Auther interface {
|
|||||||
|
|
||||||
// Token represents a user token, including expiry date
|
// Token represents a user token, including expiry date
|
||||||
type Token struct {
|
type Token struct {
|
||||||
Value string
|
Value string
|
||||||
Label string
|
Label string
|
||||||
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
|
||||||
@@ -242,57 +242,19 @@ 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedPasswordHash checks if the given password hash is a valid bcrypt hash
|
|
||||||
func AllowedPasswordHash(hash string) error {
|
|
||||||
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
|
|
||||||
return ErrPasswordHashInvalid
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error constants used by the package
|
// Error constants used by the package
|
||||||
var (
|
var (
|
||||||
ErrUnauthenticated = errors.New("unauthenticated")
|
ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrInvalidArgument = errors.New("invalid argument")
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrUserExists = errors.New("user already exists")
|
ErrUserExists = errors.New("user already exists")
|
||||||
ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate")
|
ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, 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")
|
||||||
)
|
)
|
||||||
|
|||||||
73
user/util.go
Normal file
73
user/util.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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) error {
|
||||||
|
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
|
||||||
|
return ErrPasswordHashInvalid
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
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": "சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது",
|
||||||
|
|||||||
@@ -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>
|
||||||
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
|
{!account?.provisioned ? (
|
||||||
<EditIcon />
|
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
|
||||||
</IconButton>
|
<EditIcon />
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
|
{!account?.provisioned ? (
|
||||||
{t("account_delete_title")}
|
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
|
||||||
</Button>
|
{t("account_delete_title")}
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user