Compare commits
1 Commits
v2.15.0
...
busy-timeo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c285f7ce |
15
README.md
15
README.md
@@ -1,16 +1,3 @@
|
|||||||
<div align="center" markdown="1">
|
|
||||||
<sup>Special thanks to:</sup>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
<a href="https://go.warp.dev/ntfy">
|
|
||||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/ntfy)
|
|
||||||
[Available for MacOS, Linux, & Windows](https://go.warp.dev/ntfy)<br>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||||
@@ -80,8 +67,6 @@ Thank you to our commercial sponsors, who help keep the service running and the
|
|||||||
|
|
||||||
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
|
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
|
||||||
|
|
||||||
<a href="https://go.warp.dev/ntfy"><img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png" width="160px"></a>
|
|
||||||
|
|
||||||
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
||||||
|
|
||||||
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
# default-command:
|
# default-command:
|
||||||
|
|
||||||
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
||||||
# or if you can "ntfy subscribe --from-config" directly.
|
# or if you cann "ntfy subscribe --from-config" directly.
|
||||||
#
|
#
|
||||||
# Example:
|
# Example:
|
||||||
# subscribe:
|
# subscribe:
|
||||||
|
|||||||
@@ -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.Writer, "granted read-write access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
||||||
} else if permission.IsRead() {
|
} else if permission.IsRead() {
|
||||||
fmt.Fprintf(c.App.Writer, "granted read-only access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
||||||
} else if permission.IsWrite() {
|
} else if permission.IsWrite() {
|
||||||
fmt.Fprintf(c.App.Writer, "granted write-only access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.Writer, "revoked all access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "reset access for all users")
|
fmt.Fprintln(c.App.ErrWriter, "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.Writer, "reset access for user %s\n\n", username)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "reset access for user %s and topic %s\n\n", username, topic)
|
fmt.Fprintf(c.App.ErrWriter, "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 = ", server config"
|
provisioned = ", provisioned user"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.Writer, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "- read-write access to all topics (admin role)\n")
|
fmt.Fprintf(c.App.ErrWriter, "- 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 = " (server config)"
|
grantProvisioned = ", provisioned access entry"
|
||||||
}
|
}
|
||||||
if grant.Permission.IsReadWrite() {
|
if grant.Permission.IsReadWrite() {
|
||||||
fmt.Fprintf(c.App.Writer, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
fmt.Fprintf(c.App.ErrWriter, "- 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.Writer, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
fmt.Fprintf(c.App.ErrWriter, "- 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.Writer, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.Writer, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.Writer, "- no topic-specific permissions\n")
|
fmt.Fprintf(c.App.ErrWriter, "- 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.Writer, "- read-write access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
||||||
} else if access.IsRead() {
|
} else if access.IsRead() {
|
||||||
fmt.Fprintln(c.App.Writer, "- read-only access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
||||||
} else if access.IsWrite() {
|
} else if access.IsWrite() {
|
||||||
fmt.Fprintln(c.App.Writer, "- write-only access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(c.App.Writer, "- no access to any (other) topics (server config)")
|
fmt.Fprintln(c.App.ErrWriter, "- 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, _, stdout, _ := newTestApp()
|
app, _, _, stderr := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
require.Contains(t, stdout.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
require.Contains(t, stderr.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, _, stdout, _ := newTestApp()
|
app, _, _, stderr := 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, stdout.String())
|
require.Equal(t, expected, stderr.String())
|
||||||
|
|
||||||
// See if access permissions match
|
// See if access permissions match
|
||||||
app, _, _, _ = newTestApp()
|
app, _, _, _ = newTestApp()
|
||||||
|
|||||||
117
cmd/serve.go
117
cmd/serve.go
@@ -16,10 +16,10 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/payments"
|
|
||||||
"heckel.io/ntfy/v2/server"
|
"heckel.io/ntfy/v2/server"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -48,9 +48,8 @@ var flagsServe = append(
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-users", Aliases: []string{"auth_provision_users"}, EnvVars: []string{"NTFY_AUTH_PROVISION_USERS"}, Usage: "pre-provisioned declarative users"}),
|
||||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-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-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)"}),
|
||||||
@@ -63,7 +62,6 @@ var flagsServe = append(
|
|||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "require-login", Aliases: []string{"require_login"}, EnvVars: []string{"NTFY_REQUIRE_LOGIN"}, Value: false, Usage: "all actions via the web app requires a login"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
@@ -158,9 +156,8 @@ func execServe(c *cli.Context) error {
|
|||||||
authFile := c.String("auth-file")
|
authFile := c.String("auth-file")
|
||||||
authStartupQueries := c.String("auth-startup-queries")
|
authStartupQueries := c.String("auth-startup-queries")
|
||||||
authDefaultAccess := c.String("auth-default-access")
|
authDefaultAccess := c.String("auth-default-access")
|
||||||
authUsersRaw := c.StringSlice("auth-users")
|
authProvisionUsersRaw := c.StringSlice("auth-provision-users")
|
||||||
authAccessRaw := c.StringSlice("auth-access")
|
authProvisionAccessRaw := c.StringSlice("auth-provision-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")
|
||||||
@@ -172,7 +169,6 @@ func execServe(c *cli.Context) error {
|
|||||||
webRoot := c.String("web-root")
|
webRoot := c.String("web-root")
|
||||||
enableSignup := c.Bool("enable-signup")
|
enableSignup := c.Bool("enable-signup")
|
||||||
enableLogin := c.Bool("enable-login")
|
enableLogin := c.Bool("enable-login")
|
||||||
requireLogin := c.Bool("require-login")
|
|
||||||
enableReservations := c.Bool("enable-reservations")
|
enableReservations := c.Bool("enable-reservations")
|
||||||
upstreamBaseURL := c.String("upstream-base-url")
|
upstreamBaseURL := c.String("upstream-base-url")
|
||||||
upstreamAccessToken := c.String("upstream-access-token")
|
upstreamAccessToken := c.String("upstream-access-token")
|
||||||
@@ -281,8 +277,6 @@ func execServe(c *cli.Context) error {
|
|||||||
// Check values
|
// Check values
|
||||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||||
return errors.New("if set, FCM key file must exist")
|
return errors.New("if set, FCM key file must exist")
|
||||||
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
|
|
||||||
return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)")
|
|
||||||
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
|
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
|
||||||
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
||||||
} else if keepaliveInterval < 5*time.Second {
|
} else if keepaliveInterval < 5*time.Second {
|
||||||
@@ -320,14 +314,10 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("if upstream-base-url is set, base-url must also be set")
|
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||||
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
||||||
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
||||||
} else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
|
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") {
|
||||||
return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||||
} else if enableSignup && !enableLogin {
|
} else if enableSignup && !enableLogin {
|
||||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||||
} else if requireLogin && !enableLogin {
|
|
||||||
return errors.New("cannot set require-login without also setting enable-login")
|
|
||||||
} else if !payments.Available && (stripeSecretKey != "" || stripeWebhookKey != "") {
|
|
||||||
return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)")
|
|
||||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
||||||
@@ -337,8 +327,6 @@ func execServe(c *cli.Context) error {
|
|||||||
if messageSizeLimit > 5*1024*1024 {
|
if messageSizeLimit > 5*1024*1024 {
|
||||||
return errors.New("message-size-limit cannot be higher than 5M")
|
return errors.New("message-size-limit cannot be higher than 5M")
|
||||||
}
|
}
|
||||||
} else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
|
|
||||||
return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
|
|
||||||
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
||||||
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||||
} else if behindProxy && proxyForwardedHeader == "" {
|
} else if behindProxy && proxyForwardedHeader == "" {
|
||||||
@@ -365,15 +353,11 @@ 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'")
|
||||||
}
|
}
|
||||||
authUsers, err := parseUsers(authUsersRaw)
|
authProvisionUsers, err := parseProvisionUsers(authProvisionUsersRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
authAccess, err := parseAccess(authUsers, authAccessRaw)
|
authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
authTokens, err := parseTokens(authUsers, authTokensRaw)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -406,7 +390,8 @@ func execServe(c *cli.Context) error {
|
|||||||
|
|
||||||
// Stripe things
|
// Stripe things
|
||||||
if stripeSecretKey != "" {
|
if stripeSecretKey != "" {
|
||||||
payments.Setup(stripeSecretKey)
|
stripe.EnableTelemetry = false // Whoa!
|
||||||
|
stripe.Key = stripeSecretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add default forbidden topics
|
// Add default forbidden topics
|
||||||
@@ -431,9 +416,8 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.AuthFile = authFile
|
conf.AuthFile = authFile
|
||||||
conf.AuthStartupQueries = authStartupQueries
|
conf.AuthStartupQueries = authStartupQueries
|
||||||
conf.AuthDefault = authDefault
|
conf.AuthDefault = authDefault
|
||||||
conf.AuthUsers = authUsers
|
conf.AuthProvisionedUsers = authProvisionUsers
|
||||||
conf.AuthAccess = authAccess
|
conf.AuthProvisionedAccess = authProvisionAccess
|
||||||
conf.AuthTokens = authTokens
|
|
||||||
conf.AttachmentCacheDir = attachmentCacheDir
|
conf.AttachmentCacheDir = attachmentCacheDir
|
||||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
@@ -479,7 +463,6 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.BillingContact = billingContact
|
conf.BillingContact = billingContact
|
||||||
conf.EnableSignup = enableSignup
|
conf.EnableSignup = enableSignup
|
||||||
conf.EnableLogin = enableLogin
|
conf.EnableLogin = enableLogin
|
||||||
conf.RequireLogin = requireLogin
|
|
||||||
conf.EnableReservations = enableReservations
|
conf.EnableReservations = enableReservations
|
||||||
conf.EnableMetrics = enableMetrics
|
conf.EnableMetrics = enableMetrics
|
||||||
conf.MetricsListenHTTP = metricsListenHTTP
|
conf.MetricsListenHTTP = metricsListenHTTP
|
||||||
@@ -548,63 +531,63 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseUsers(usersRaw []string) ([]*user.User, error) {
|
func parseProvisionUsers(usersRaw []string) ([]*user.User, error) {
|
||||||
users := make([]*user.User, 0)
|
provisionUsers := 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-users: %s, expected format: 'name:hash:role'", userLine)
|
return nil, fmt.Errorf("invalid auth-provision-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-users: %s, username invalid", userLine)
|
return nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine)
|
||||||
} else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
|
} else if err := user.AllowedPasswordHash(passwordHash); err != nil {
|
||||||
return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
|
return nil, fmt.Errorf("invalid auth-provision-users: %s, %s", userLine, err.Error())
|
||||||
} else if !user.AllowedRole(role) {
|
} else if !user.AllowedRole(role) {
|
||||||
return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
|
return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
|
||||||
}
|
}
|
||||||
users = append(users, &user.User{
|
provisionUsers = append(provisionUsers, &user.User{
|
||||||
Name: username,
|
Name: username,
|
||||||
Hash: passwordHash,
|
Hash: passwordHash,
|
||||||
Role: role,
|
Role: role,
|
||||||
Provisioned: true,
|
Provisioned: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return users, nil
|
return provisionUsers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) {
|
func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) {
|
||||||
access := make(map[string][]*user.Grant)
|
access := make(map[string][]*user.Grant)
|
||||||
for _, accessLine := range accessRaw {
|
for _, accessLine := range provisionAccessRaw {
|
||||||
parts := strings.Split(accessLine, ":")
|
parts := strings.Split(accessLine, ":")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine)
|
return nil, fmt.Errorf("invalid auth-provision-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
|
||||||
}
|
}
|
||||||
u, exists := util.Find(users, func(u *user.User) bool {
|
provisionUser, exists := util.Find(provisionUsers, 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-access: %s, user %s is not provisioned", accessLine, username)
|
return nil, fmt.Errorf("invalid auth-provision-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-access: %s, username %s invalid", accessLine, username)
|
return nil, fmt.Errorf("invalid auth-provision-access: %s, username %s invalid", accessLine, username)
|
||||||
} else if u.Role != user.RoleUser {
|
} else if provisionUser.Role != user.RoleUser {
|
||||||
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
topic := strings.TrimSpace(parts[1])
|
topic := strings.TrimSpace(parts[1])
|
||||||
if !user.AllowedTopicPattern(topic) {
|
if !user.AllowedTopicPattern(topic) {
|
||||||
return nil, fmt.Errorf("invalid auth-access: %s, topic pattern %s invalid", accessLine, topic)
|
return nil, fmt.Errorf("invalid auth-provision-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-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error())
|
return nil, fmt.Errorf("invalid auth-provision-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)
|
||||||
@@ -618,42 +601,6 @@ func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Gra
|
|||||||
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,461 +14,9 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/client"
|
"heckel.io/ntfy/v2/client"
|
||||||
"heckel.io/ntfy/v2/test"
|
"heckel.io/ntfy/v2/test"
|
||||||
"heckel.io/ntfy/v2/user"
|
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseUsers_Success(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input []string
|
|
||||||
expected []*user.User
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "single user",
|
|
||||||
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
|
||||||
expected: []*user.User{
|
|
||||||
{
|
|
||||||
Name: "alice",
|
|
||||||
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
|
|
||||||
Role: user.RoleUser,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple users with different roles",
|
|
||||||
input: []string{
|
|
||||||
"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
|
|
||||||
"bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
|
|
||||||
},
|
|
||||||
expected: []*user.User{
|
|
||||||
{
|
|
||||||
Name: "alice",
|
|
||||||
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
|
|
||||||
Role: user.RoleUser,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "bob",
|
|
||||||
Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
|
|
||||||
Role: user.RoleAdmin,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
input: []string{},
|
|
||||||
expected: []*user.User{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "user with special characters in name",
|
|
||||||
input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"},
|
|
||||||
expected: []*user.User{
|
|
||||||
{
|
|
||||||
Name: "alice.test+123@example.com",
|
|
||||||
Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
|
|
||||||
Role: user.RoleUser,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, err := parseUsers(tt.input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, result, len(tt.expected))
|
|
||||||
|
|
||||||
for i, expectedUser := range tt.expected {
|
|
||||||
assert.Equal(t, expectedUser.Name, result[i].Name)
|
|
||||||
assert.Equal(t, expectedUser.Hash, result[i].Hash)
|
|
||||||
assert.Equal(t, expectedUser.Role, result[i].Role)
|
|
||||||
assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseUsers_Errors(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input []string
|
|
||||||
error string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "invalid format - too few parts",
|
|
||||||
input: []string{"alice:hash"},
|
|
||||||
error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid format - too many parts",
|
|
||||||
input: []string{"alice:hash:role:extra"},
|
|
||||||
error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid username",
|
|
||||||
input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
|
||||||
error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid password hash - wrong prefix",
|
|
||||||
input: []string{"alice:plaintext:user"},
|
|
||||||
error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid role",
|
|
||||||
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
|
|
||||||
error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty username",
|
|
||||||
input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
|
||||||
error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, err := parseUsers(tt.input)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Nil(t, result)
|
|
||||||
assert.Contains(t, err.Error(), tt.error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAccess_Success(t *testing.T) {
|
|
||||||
users := []*user.User{
|
|
||||||
{Name: "alice", Role: user.RoleUser},
|
|
||||||
{Name: "bob", Role: user.RoleUser},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
users []*user.User
|
|
||||||
input []string
|
|
||||||
expected map[string][]*user.Grant
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "single access entry",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:mytopic:read-write"},
|
|
||||||
expected: map[string][]*user.Grant{
|
|
||||||
"alice": {
|
|
||||||
{
|
|
||||||
TopicPattern: "mytopic",
|
|
||||||
Permission: user.PermissionReadWrite,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple access entries for same user",
|
|
||||||
users: users,
|
|
||||||
input: []string{
|
|
||||||
"alice:topic1:read-only",
|
|
||||||
"alice:topic2:write-only",
|
|
||||||
},
|
|
||||||
expected: map[string][]*user.Grant{
|
|
||||||
"alice": {
|
|
||||||
{
|
|
||||||
TopicPattern: "topic1",
|
|
||||||
Permission: user.PermissionRead,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
TopicPattern: "topic2",
|
|
||||||
Permission: user.PermissionWrite,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "access for everyone",
|
|
||||||
users: users,
|
|
||||||
input: []string{"everyone:publictopic:read-only"},
|
|
||||||
expected: map[string][]*user.Grant{
|
|
||||||
user.Everyone: {
|
|
||||||
{
|
|
||||||
TopicPattern: "publictopic",
|
|
||||||
Permission: user.PermissionRead,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard topic pattern",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:topic*:read-write"},
|
|
||||||
expected: map[string][]*user.Grant{
|
|
||||||
"alice": {
|
|
||||||
{
|
|
||||||
TopicPattern: "topic*",
|
|
||||||
Permission: user.PermissionReadWrite,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
users: users,
|
|
||||||
input: []string{},
|
|
||||||
expected: map[string][]*user.Grant{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "deny-all permission",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:secretopic:deny-all"},
|
|
||||||
expected: map[string][]*user.Grant{
|
|
||||||
"alice": {
|
|
||||||
{
|
|
||||||
TopicPattern: "secretopic",
|
|
||||||
Permission: user.PermissionDenyAll,
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, err := parseAccess(tt.users, tt.input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAccess_Errors(t *testing.T) {
|
|
||||||
users := []*user.User{
|
|
||||||
{Name: "alice", Role: user.RoleUser},
|
|
||||||
{Name: "admin", Role: user.RoleAdmin},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
users []*user.User
|
|
||||||
input []string
|
|
||||||
error string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "invalid format - too few parts",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:topic"},
|
|
||||||
error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid format - too many parts",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:topic:read:extra"},
|
|
||||||
error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "user not provisioned",
|
|
||||||
users: users,
|
|
||||||
input: []string{"charlie:topic:read"},
|
|
||||||
error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "admin user cannot have ACL entries",
|
|
||||||
users: users,
|
|
||||||
input: []string{"admin:topic:read"},
|
|
||||||
error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid topic pattern",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:topic-with-invalid-chars!:read"},
|
|
||||||
error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid permission",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:topic:invalid-permission"},
|
|
||||||
error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, err := parseAccess(tt.users, tt.input)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Nil(t, result)
|
|
||||||
assert.Contains(t, err.Error(), tt.error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTokens_Success(t *testing.T) {
|
|
||||||
users := []*user.User{
|
|
||||||
{Name: "alice"},
|
|
||||||
{Name: "bob"},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
users []*user.User
|
|
||||||
input []string
|
|
||||||
expected map[string][]*user.Token
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "single token without label",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"},
|
|
||||||
expected: map[string][]*user.Token{
|
|
||||||
"alice": {
|
|
||||||
{
|
|
||||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
|
||||||
Label: "",
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single token with label",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"},
|
|
||||||
expected: map[string][]*user.Token{
|
|
||||||
"alice": {
|
|
||||||
{
|
|
||||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
|
||||||
Label: "My Phone",
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple tokens for same user",
|
|
||||||
users: users,
|
|
||||||
input: []string{
|
|
||||||
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
|
|
||||||
"alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop",
|
|
||||||
},
|
|
||||||
expected: map[string][]*user.Token{
|
|
||||||
"alice": {
|
|
||||||
{
|
|
||||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
|
||||||
Label: "Phone",
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
|
|
||||||
Label: "Laptop",
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tokens for multiple users",
|
|
||||||
users: users,
|
|
||||||
input: []string{
|
|
||||||
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
|
|
||||||
"bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet",
|
|
||||||
},
|
|
||||||
expected: map[string][]*user.Token{
|
|
||||||
"alice": {
|
|
||||||
{
|
|
||||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
|
||||||
Label: "Phone",
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"bob": {
|
|
||||||
{
|
|
||||||
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
|
|
||||||
Label: "Tablet",
|
|
||||||
Provisioned: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
users: users,
|
|
||||||
input: []string{},
|
|
||||||
expected: map[string][]*user.Token{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, err := parseTokens(tt.users, tt.input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTokens_Errors(t *testing.T) {
|
|
||||||
users := []*user.User{
|
|
||||||
{Name: "alice"},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
users []*user.User
|
|
||||||
input []string
|
|
||||||
error string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "invalid format - too few parts",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice"},
|
|
||||||
error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid format - too many parts",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:token:label:extra:parts"},
|
|
||||||
error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "user not provisioned",
|
|
||||||
users: users,
|
|
||||||
input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"},
|
|
||||||
error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid token format",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:invalid-token"},
|
|
||||||
error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "token too short",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:tk_short"},
|
|
||||||
error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "token without prefix",
|
|
||||||
users: users,
|
|
||||||
input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"},
|
|
||||||
error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, err := parseTokens(tt.users, tt.input)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Nil(t, result)
|
|
||||||
assert.Contains(t, err.Error(), tt.error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||||
|
|||||||
32
cmd/tier.go
32
cmd/tier.go
@@ -182,7 +182,7 @@ func execTierAdd(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
if tier, _ := manager.Tier(code); tier != nil {
|
if tier, _ := manager.Tier(code); tier != nil {
|
||||||
if c.Bool("ignore-exists") {
|
if c.Bool("ignore-exists") {
|
||||||
fmt.Fprintf(c.App.Writer, "tier %s already exists (exited successfully)\n", code)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "tier added\n\n")
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "tier updated\n\n")
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "tier %s removed\n", code)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||||
fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name)
|
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
|
||||||
fmt.Fprintf(c.App.Writer, "- Message limit: %d\n", tier.MessageLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
||||||
fmt.Fprintf(c.App.Writer, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||||
fmt.Fprintf(c.App.Writer, "- Email limit: %d\n", tier.EmailLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||||
fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
||||||
fmt.Fprintf(c.App.Writer, "- Reservation limit: %d\n", tier.ReservationLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||||
fmt.Fprintf(c.App.Writer, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||||
fmt.Fprintf(c.App.Writer, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||||
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 expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||||
fmt.Fprintf(c.App.Writer, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||||
fmt.Fprintf(c.App.Writer, "- Stripe prices (monthly/yearly): %s\n", prices)
|
fmt.Fprintf(c.App.ErrWriter, "- 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, _, stdout, _ := newTestApp()
|
app, _, _, stderr := 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, stdout.String(), "tier added\n\ntier pro (id: ti_")
|
require.Contains(t, stderr.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, _, stdout, _ = newTestApp()
|
app, _, _, stderr = newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
require.Nil(t, runTierCommand(app, conf, "list"))
|
||||||
require.Contains(t, stdout.String(), "tier pro (id: ti_")
|
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
||||||
require.Contains(t, stdout.String(), "- Name: Pro")
|
require.Contains(t, stderr.String(), "- Name: Pro")
|
||||||
require.Contains(t, stdout.String(), "- Message limit: 1234")
|
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
||||||
|
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, _, stderr = 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, stdout.String(), "- Message limit: 999")
|
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||||
require.Contains(t, stdout.String(), "- Message expiry duration: 48h")
|
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
||||||
require.Contains(t, stdout.String(), "- Email limit: 91")
|
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||||
require.Contains(t, stdout.String(), "- Reservation limit: 98")
|
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||||
require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB")
|
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||||
require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h")
|
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
||||||
require.Contains(t, stdout.String(), "- Attachment total size limit: 10.0 GB")
|
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
||||||
require.Contains(t, stdout.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||||
|
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, _, stderr = newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
||||||
require.Contains(t, stdout.String(), "tier pro removed")
|
require.Contains(t, stderr.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,15 +72,6 @@ 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.
|
||||||
|
|
||||||
@@ -121,19 +112,19 @@ func execTokenAdd(c *cli.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := manager.User(username)
|
u, err := manager.User(username)
|
||||||
if errors.Is(err, user.ErrUserNotFound) {
|
if 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(), false)
|
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if expires.Unix() == 0 {
|
if expires.Unix() == 0 {
|
||||||
fmt.Fprintf(c.App.Writer, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.Writer, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -150,7 +141,7 @@ func execTokenDel(c *cli.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := manager.User(username)
|
u, err := manager.User(username)
|
||||||
if errors.Is(err, user.ErrUserNotFound) {
|
if 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
|
||||||
@@ -158,7 +149,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.Writer, "token %s for user %s removed\n", token, username)
|
fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +165,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 errors.Is(err, user.ErrUserNotFound) {
|
if 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
|
||||||
@@ -192,15 +183,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.Writer, "user %s has no access tokens\n", username)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "user %s\n", u.Name)
|
fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name)
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
var label, expires, provisioned string
|
var label, expires string
|
||||||
if t.Label != "" {
|
if t.Label != "" {
|
||||||
label = fmt.Sprintf(" (%s)", t.Label)
|
label = fmt.Sprintf(" (%s)", t.Label)
|
||||||
}
|
}
|
||||||
@@ -209,19 +200,11 @@ 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))
|
||||||
}
|
}
|
||||||
if t.Provisioned {
|
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))
|
||||||
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.Writer, "no users with tokens\n")
|
fmt.Fprintf(c.App.ErrWriter, "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, stdout, _ := newTestApp()
|
app, stdin, _, stderr := 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, stdout.String(), "user phil added with role user")
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, _, stderr = 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`, stdout.String())
|
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String())
|
||||||
|
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, _, stderr = 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 .+`, stdout.String())
|
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String())
|
||||||
re := regexp.MustCompile(`tk_\w+`)
|
re := regexp.MustCompile(`tk_\w+`)
|
||||||
token := re.FindString(stdout.String())
|
token := re.FindString(stderr.String())
|
||||||
|
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, _, stderr = 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), stdout.String())
|
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String())
|
||||||
|
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, _, stderr = newTestApp()
|
||||||
require.Nil(t, runTokenCommand(app, conf, "list"))
|
require.Nil(t, runTokenCommand(app, conf, "list"))
|
||||||
require.Equal(t, "no users with tokens\n", stdout.String())
|
require.Equal(t, "no users with tokens\n", stderr.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-users.
|
for predefined users in the server config file, in auth-provision-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.Writer, "user %s already exists (exited successfully)\n", username)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "user %s added with role %s\n", username, role)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "user %s removed\n", username)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "changed password for user %s\n", username)
|
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,20 +301,24 @@ 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.Writer, "changed role for user %s to %s\n", username, role)
|
fmt.Fprintf(c.App.ErrWriter, "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 := user.HashPassword(password)
|
hash, err := manager.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.Fprintln(c.App.Writer, hash)
|
fmt.Fprintf(c.App.Writer, "%s\n", string(hash))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,12 +343,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.Writer, "removed tier from user %s\n", username)
|
fmt.Fprintf(c.App.ErrWriter, "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.Writer, "changed tier for user %s to %s\n", username, tier)
|
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -378,7 +382,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
|||||||
Filename: authFile,
|
Filename: authFile,
|
||||||
StartupQueries: authStartupQueries,
|
StartupQueries: authStartupQueries,
|
||||||
DefaultAccess: authDefault,
|
DefaultAccess: authDefault,
|
||||||
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
|
ProvisionEnabled: false, // 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, stdout, _ := newTestApp()
|
app, stdin, _, stderr := 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, stdout.String(), "user phil added with role user")
|
require.Contains(t, stderr.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, stdout, _ := newTestApp()
|
app, stdin, _, stderr := 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, stdout.String(), "user phil added with role user")
|
require.Contains(t, stderr.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, stdout, _ := newTestApp()
|
app, stdin, _, stderr := 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, stdout.String(), "user phil added with role admin")
|
require.Contains(t, stderr.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,27 +60,19 @@ 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, stdout, _ := newTestApp()
|
app, stdin, _, stderr := 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, stdout.String(), "user phil added with role user")
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
|
||||||
// Change pass
|
// Change pass
|
||||||
app, stdin, stdout, _ = newTestApp()
|
app, stdin, _, stderr = 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, stdout.String(), "changed password for user phil")
|
require.Contains(t, stderr.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) {
|
||||||
@@ -88,15 +80,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, stdout, _ := newTestApp()
|
app, stdin, _, stderr := 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, stdout.String(), "user phil added with role user")
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
|
||||||
// Change role
|
// Change role
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, _, stderr = newTestApp()
|
||||||
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
||||||
require.Contains(t, stdout.String(), "changed role for user phil to admin")
|
require.Contains(t, stderr.String(), "changed role for user phil to admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_User_Delete(t *testing.T) {
|
func TestCLI_User_Delete(t *testing.T) {
|
||||||
@@ -104,15 +96,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, stdout, _ := newTestApp()
|
app, stdin, _, stderr := 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, stdout.String(), "user phil added with role user")
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
app, _, stdout, _ = newTestApp()
|
app, _, _, stderr = newTestApp()
|
||||||
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
||||||
require.Contains(t, stdout.String(), "user phil removed")
|
require.Contains(t, stderr.String(), "user phil removed")
|
||||||
|
|
||||||
// Delete user again (does not exist)
|
// Delete user again (does not exist)
|
||||||
app, _, _, _ = newTestApp()
|
app, _, _, _ = newTestApp()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !noserver && !nowebpush
|
//go:build !noserver
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
@@ -53,9 +53,9 @@ web-push-private-key: %s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = fmt.Fprintf(c.App.Writer, "Web Push keys written to %s.\n", outputFile)
|
_, err = fmt.Fprintf(c.App.ErrWriter, "Web Push keys written to %s.\n", outputFile)
|
||||||
} else {
|
} else {
|
||||||
_, err = fmt.Fprintf(c.App.Writer, `Web Push keys generated. Add the following lines to your config file:
|
_, err = fmt.Fprintf(c.App.ErrWriter, `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,7 +1,6 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -10,18 +9,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
||||||
app, _, stdout, _ := newTestApp()
|
app, _, _, stderr := newTestApp()
|
||||||
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
|
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
|
||||||
require.Contains(t, stdout.String(), "Web Push keys generated.")
|
require.Contains(t, stderr.String(), "Web Push keys generated.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_WebPush_WriteKeysToFile(t *testing.T) {
|
func TestCLI_WebPush_WriteKeysToFile(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
app, _, _, stderr := newTestApp()
|
||||||
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, stdout.String(), "Web Push keys written to key-file.yaml")
|
require.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml")
|
||||||
require.FileExists(t, filepath.Join(tempDir, "key-file.yaml"))
|
require.FileExists(t, "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 {
|
||||||
|
|||||||
203
docs/config.md
203
docs/config.md
@@ -88,7 +88,6 @@ using Docker Compose (i.e. `docker-compose.yml`):
|
|||||||
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
|
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
|
||||||
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
|
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
|
||||||
NTFY_AUTH_DEFAULT_ACCESS: deny-all
|
NTFY_AUTH_DEFAULT_ACCESS: deny-all
|
||||||
NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
|
|
||||||
NTFY_BEHIND_PROXY: true
|
NTFY_BEHIND_PROXY: true
|
||||||
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
|
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
|
||||||
NTFY_ENABLE_LOGIN: true
|
NTFY_ENABLE_LOGIN: true
|
||||||
@@ -189,31 +188,19 @@ 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, **configure the following options**:
|
To set up auth, simply **configure the following two 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`. **If you are setting up a private instance,
|
set to `read-write` (default), `read-only`, `write-only` or `deny-all`.
|
||||||
you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)).
|
|
||||||
|
|
||||||
Once configured, you can use
|
Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command
|
||||||
|
lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these
|
||||||
- the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles)
|
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 access` command and the `auth-access` option to [modify the access control list](#access-control-list-acl)
|
accessing them has the right permissions.
|
||||||
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)).
|
||||||
@@ -234,54 +221,12 @@ 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. Entries can be created in
|
Each entry represents the access permissions for a user to a specific topic or topic pattern.
|
||||||
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:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -337,51 +282,6 @@ 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
|
||||||
@@ -392,12 +292,6 @@ 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).
|
||||||
|
|
||||||
@@ -408,7 +302,6 @@ 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:**
|
||||||
@@ -416,89 +309,32 @@ ntfy token generate # Generate random token, can be used in aut
|
|||||||
$ 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_7eevizlsiwf9yi4uxsrs83r4352o0 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
|
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (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).
|
||||||
|
|
||||||
#### Tokens via the config
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
### Example: Private instance
|
||||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`,
|
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`
|
=== "/etc/ntfy/server.yml"
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Config via env variables"
|
After that, simply create an `admin` user:
|
||||||
``` yaml
|
|
||||||
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
```
|
||||||
NTFY_AUTH_DEFAULT_ACCESS='deny-all'
|
$ ntfy user add --role=admin phil
|
||||||
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user'
|
password: mypass
|
||||||
NTFY_AUTH_ACCESS='backup-service:backups:rw'
|
confirm: mypass
|
||||||
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token'
|
user phil added with role admin
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example:
|
||||||
|
|
||||||
Here's a simple example (using the credentials of the `phil` user):
|
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
@@ -1698,7 +1534,6 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||||
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||||
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||||
| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login |
|
|
||||||
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||||
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||||
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
||||||
|
|||||||
@@ -30,56 +30,50 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
|
tar zxvf ntfy_2.13.0_linux_amd64.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.13.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.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.15.0/ntfy_2.15.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.13.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.13.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.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.15.0/ntfy_2.15.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.13.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.13.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.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.15.0/ntfy_2.15.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.13.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.13.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Debian/Ubuntu repository
|
## Debian/Ubuntu repository
|
||||||
|
Installation via Debian repository:
|
||||||
!!! info
|
|
||||||
As of September 2025, **the official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh/apt)**.
|
|
||||||
The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely
|
|
||||||
go away soon. I suspect I will phase it out some time in early 2026.
|
|
||||||
|
|
||||||
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 6B7C CFDB 962D 4F1E C4AF`):
|
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||||
sudo apt install apt-transport-https
|
sudo apt install apt-transport-https
|
||||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
sudo sh -c "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
@@ -89,10 +83,10 @@ Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4
|
|||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||||
sudo apt install apt-transport-https
|
sudo apt install apt-transport-https
|
||||||
echo "deb [arch=armhf signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
sudo sh -c "echo 'deb [arch=armhf signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
@@ -102,10 +96,10 @@ Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4
|
|||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||||
sudo apt install apt-transport-https
|
sudo apt install apt-transport-https
|
||||||
echo "deb [arch=arm64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
sudo sh -c "echo 'deb [arch=arm64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
@@ -116,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.15.0/ntfy_2.15.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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
|
||||||
@@ -124,7 +118,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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
|
||||||
@@ -132,7 +126,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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
|
||||||
@@ -140,7 +134,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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
|
||||||
@@ -150,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.15.0/ntfy_2.15.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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.15.0/ntfy_2.15.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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.15.0/ntfy_2.15.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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.15.0/ntfy_2.15.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -201,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.15.0/ntfy_2.15.0_darwin_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz
|
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
|
||||||
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
|
tar zxvf ntfy_2.13.0_darwin_all.tar.gz
|
||||||
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.13.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.13.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -227,9 +221,10 @@ simply run:
|
|||||||
brew install ntfy
|
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.15.0/ntfy_2.15.0_windows_amd64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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).
|
||||||
@@ -306,7 +301,6 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true # needed, if healthcheck is used. Prevents zombie processes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
|
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
|
||||||
@@ -325,6 +319,7 @@ The setup for Kubernetes is very similar to that for Docker, and requires a fair
|
|||||||
are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone
|
are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone
|
||||||
unmanned pod.
|
unmanned pod.
|
||||||
|
|
||||||
|
|
||||||
=== "deployment"
|
=== "deployment"
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
|
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
|
||||||
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
||||||
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
|
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
|
||||||
- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.
|
|
||||||
|
|
||||||
## Projects + scripts
|
## Projects + scripts
|
||||||
|
|
||||||
@@ -175,11 +174,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)
|
- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)
|
||||||
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
|
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
|
||||||
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
|
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
|
||||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Overseerr and Maintainerr webhook notification to ntfy helper service (C#)
|
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
|
||||||
- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform
|
|
||||||
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
|
|
||||||
- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.
|
|
||||||
- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)
|
|
||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
|
|||||||
@@ -625,7 +625,7 @@ them with a comma, e.g. `tag1,tag2,tag3`.
|
|||||||
or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
||||||
|
|
||||||
## Markdown formatting
|
## Markdown formatting
|
||||||
_Supported on:_ :material-android: :material-firefox:
|
_Supported on:_ :material-firefox:
|
||||||
|
|
||||||
You can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/) 🤩. That means you can use
|
You can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/) 🤩. That means you can use
|
||||||
**bold text**, *italicized text*, links, images, and more. Supported Markdown features (web app only for now):
|
**bold text**, *italicized text*, links, images, and more. Supported Markdown features (web app only for now):
|
||||||
@@ -1106,7 +1106,7 @@ Which will result in a notification that looks like this:
|
|||||||
When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your
|
When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your
|
||||||
webhook payload.
|
webhook payload.
|
||||||
|
|
||||||
Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh).
|
Inline templates are most useful for templated one-off messages, of if you do not control the ntfy server (e.g., if you're using ntfy.sh).
|
||||||
Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead,
|
Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead,
|
||||||
if you control the ntfy server, as templates are much easier to maintain.
|
if you control the ntfy server, as templates are much easier to maintain.
|
||||||
|
|
||||||
@@ -3679,13 +3679,13 @@ authParam = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM0
|
|||||||
The following command will generate the appropriate value for you on *nix systems:
|
The following command will generate the appropriate value for you on *nix systems:
|
||||||
|
|
||||||
```
|
```
|
||||||
echo -n "Basic `echo -n 'testuser:fakepassword' | base64 -w0`" | base64 -w0 | tr -d '='
|
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
|
||||||
```
|
```
|
||||||
|
|
||||||
For access tokens, you can use this instead:
|
For access tokens, you can use this instead:
|
||||||
|
|
||||||
```
|
```
|
||||||
echo -n "Bearer faketoken" | base64 -w0 | tr -d '='
|
echo -n "Bearer faketoken" | base64 | tr -d '='
|
||||||
```
|
```
|
||||||
|
|
||||||
## Advanced features
|
## Advanced features
|
||||||
|
|||||||
@@ -2,73 +2,7 @@
|
|||||||
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.15.0
|
### ntfy server v2.13.0
|
||||||
Released Nov 16, 2025
|
|
||||||
|
|
||||||
This release adds a `require-login` flag to topics, which forces users to log in before they can
|
|
||||||
use the web app. This is useful for self-hosters and will obviously not be enabled on ntfy.sh.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* Add `require-login` flag to redirect to login page if not logged in ([#1434](https://github.com/binwiederhier/ntfy/pull/1434)/[#238](https://github.com/binwiederhier/ntfy/issues/238)/[#1329](https://github.com/binwiederhier/ntfy/pull/1329), thanks to [@theatischbein](https://github.com/theatischbein) for implementing most of this)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* The official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh) ([#1357](https://github.com/binwiederhier/ntfy/issues/1357)/[#1401](https://github.com/binwiederhier/ntfy/issues/1401), thanks to [@skibbipl](https://github.com/skibbipl) and [@lduesing](https://github.com/lduesing) for reporting)
|
|
||||||
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
|
|
||||||
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
|
|
||||||
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
|
|
||||||
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
|
|
||||||
|
|
||||||
## ntfy Android app v1.17.13
|
|
||||||
Released October 21, 2025
|
|
||||||
|
|
||||||
This release makes changes to comply with the Google Play policies. See [#1463](https://github.com/binwiederhier/ntfy/issues/1463)
|
|
||||||
or [ef57cd1](https://github.com/binwiederhier/ntfy-android/commit/ef57cd1374118b3e4d7a7ab496afe337e714fff7) for details.
|
|
||||||
|
|
||||||
The policies do not allow directly or indirectly linking to paid plans or donation links that do not go through Google Play.
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
|
|
||||||
- Remove the "Donate" button from menu (all variants)
|
|
||||||
- Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants)
|
|
||||||
- Remove links to ntfy docs and issue tracker (Play variant only)
|
|
||||||
- Remove how-to links to ntfy.sh in a few places (Play variant only)
|
|
||||||
- Remove "Copy topic address" from subscription menu (Play variant only)
|
|
||||||
|
|
||||||
## ntfy Android app v1.17.8
|
|
||||||
Released September 23, 2025
|
|
||||||
|
|
||||||
This is largely a maintenance update to ensure the SDK is up-to-date.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* Markdown is now rendered if "Markdown: yes" was passed ([#310](https://github.com/binwiederhier/ntfy/issues/310), thanks to [@NiNiyas](https://github.com/NiNiyas) for reporting)
|
|
||||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
|
||||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
|
||||||
* Bumped all dependencies to the latest versions (no ticket)
|
|
||||||
|
|
||||||
## 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
|
|
||||||
Released July 10, 2025
|
Released July 10, 2025
|
||||||
|
|
||||||
This is a relatively small release, mainly to support IPv6 and to add more sophisticated
|
This is a relatively small release, mainly to support IPv6 and to add more sophisticated
|
||||||
@@ -87,7 +21,7 @@ ntfy will always remain open source.
|
|||||||
* Update new languages from Weblate. Thanks to all the contributors!
|
* Update new languages from Weblate. Thanks to all the contributors!
|
||||||
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
|
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
|
||||||
|
|
||||||
## ntfy server v2.12.0
|
### ntfy server v2.12.0
|
||||||
Released May 29, 2025
|
Released May 29, 2025
|
||||||
|
|
||||||
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
|
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
|
||||||
@@ -147,7 +81,7 @@ user support in Discord/Matrix/GitHub! You rock, man!
|
|||||||
* Update new languages from Weblate. Thanks to all the contributors!
|
* Update new languages from Weblate. Thanks to all the contributors!
|
||||||
* Added Tamil (தமிழ்) as a new language to the web app
|
* Added Tamil (தமிழ்) as a new language to the web app
|
||||||
|
|
||||||
## ntfy server v2.11.0
|
### ntfy server v2.11.0
|
||||||
Released May 13, 2024
|
Released May 13, 2024
|
||||||
|
|
||||||
This is a tiny release that fixes a database index issue that caused performance issues on ntfy.sh. It also fixes a bug
|
This is a tiny release that fixes a database index issue that caused performance issues on ntfy.sh. It also fixes a bug
|
||||||
@@ -162,7 +96,7 @@ and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the
|
|||||||
* Do not set rate visitor for non-eligible topics (no ticket)
|
* Do not set rate visitor for non-eligible topics (no ticket)
|
||||||
* Do not cache `config.js` ([#1098](https://github.com/binwiederhier/ntfy/pull/1098), thanks to [@wunter8](https://github.com/wunter8))
|
* Do not cache `config.js` ([#1098](https://github.com/binwiederhier/ntfy/pull/1098), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
|
||||||
## ntfy server v2.10.0
|
### ntfy server v2.10.0
|
||||||
Released Mar 27, 2024
|
Released Mar 27, 2024
|
||||||
|
|
||||||
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||||
@@ -173,7 +107,7 @@ This is great for services that let you specify a webhook URL but do not let you
|
|||||||
|
|
||||||
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||||
|
|
||||||
## ntfy server v2.9.0
|
### ntfy server v2.9.0
|
||||||
Released Mar 7, 2024
|
Released Mar 7, 2024
|
||||||
|
|
||||||
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||||
@@ -1518,4 +1452,25 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
_Nothing to see, move along ..._
|
### 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)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||||
|
* Bumped all dependencies to the latest versions (no ticket)
|
||||||
|
|
||||||
|
**Additional languages:**
|
||||||
|
|
||||||
|
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
||||||
|
|||||||
@@ -8,18 +8,13 @@ contribute, or [build your own](../develop.md).
|
|||||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.png"></a>
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.png"></a>
|
||||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="../../static/img/badge-appstore.png"></a>
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="../../static/img/badge-appstore.png"></a>
|
||||||
|
|
||||||
You can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
|
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||||
[F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), or via the APKs from [GitHub Releases](https://github.com/binwiederhier/ntfy-android/releases).
|
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||||
The Google Play and F-Droid releases are largely identical, with the one exception that the F-Droid flavor does not use Firebase.
|
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||||
The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
|
||||||
|
|
||||||
Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.
|
Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.
|
||||||
The PWA is a website that you can add to your home screen, and it will behave just like a native app.
|
The PWA is a website that you can add to your home screen, and it will behave just like a native app.
|
||||||
|
|
||||||
If you're downloading the APKs from [GitHub](https://github.com/binwiederhier/ntfy-android/releases), they are signed with
|
|
||||||
a certificate with the following SHA-256 fingerprint: `6e145d7ae685eff75468e5067e03a6c3645453343e4e181dac8b6b17ff67489d`.
|
|
||||||
You can also query the DNS TXT records for `ntfy.sh` to find this fingerprint.
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||||
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
|
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
|
||||||
|
|||||||
102
go.mod
102
go.mod
@@ -1,27 +1,27 @@
|
|||||||
module heckel.io/ntfy/v2
|
module heckel.io/ntfy/v2
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24
|
||||||
|
|
||||||
toolchain go1.24.5
|
toolchain go1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.20.0 // indirect
|
cloud.google.com/go/firestore v1.18.0 // indirect
|
||||||
cloud.google.com/go/storage v1.57.2 // indirect
|
cloud.google.com/go/storage v1.55.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.11
|
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.32
|
github.com/mattn/go-sqlite3 v1.14.28
|
||||||
github.com/olebedev/when v1.1.0
|
github.com/olebedev/when v1.1.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/urfave/cli/v2 v2.27.7
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
golang.org/x/crypto v0.44.0
|
golang.org/x/crypto v0.40.0
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.18.0
|
golang.org/x/sync v0.16.0
|
||||||
golang.org/x/term v0.37.0
|
golang.org/x/term v0.33.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.12.0
|
||||||
google.golang.org/api v0.256.0
|
google.golang.org/api v0.242.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,75 +30,75 @@ 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.18.0
|
firebase.google.com/go/v4 v4.17.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.23.2
|
github.com/prometheus/client_golang v1.22.0
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0
|
github.com/stripe/stripe-go/v74 v74.30.0
|
||||||
golang.org/x/text v0.31.0
|
golang.org/x/text v0.27.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cel.dev/expr v0.25.1 // indirect
|
cel.dev/expr v0.24.0 // indirect
|
||||||
cloud.google.com/go v0.123.0 // indirect
|
cloud.google.com/go v0.121.4 // indirect
|
||||||
cloud.google.com/go/auth v0.17.0 // indirect
|
cloud.google.com/go/auth v0.16.3 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||||
cloud.google.com/go/iam v1.5.3 // indirect
|
cloud.google.com/go/iam v1.5.2 // indirect
|
||||||
cloud.google.com/go/longrunning v0.7.0 // indirect
|
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // 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.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.1 // 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.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.3 // 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
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.2 // indirect
|
github.com/prometheus/common v0.65.0 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
github.com/zeebo/errs v1.4.0 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sys v0.38.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-20251111163417-95abcf5c77ba // indirect
|
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect
|
||||||
google.golang.org/grpc v1.76.0 // indirect
|
google.golang.org/grpc v1.73.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // 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
|
||||||
)
|
)
|
||||||
|
|||||||
214
go.sum
214
go.sum
@@ -1,41 +1,41 @@
|
|||||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs=
|
||||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s=
|
||||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
|
||||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||||
cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo=
|
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
|
||||||
cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
|
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
|
||||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
||||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
||||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||||
cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4=
|
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
|
||||||
cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk=
|
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
|
||||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||||
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
firebase.google.com/go/v4 v4.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE=
|
||||||
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||||
@@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e h1:gt7U1Igw0xbJdyaCM5H2CnlAlPSkzrhsebQB6WQWjLA=
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||||
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -60,18 +60,18 @@ github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBME
|
|||||||
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
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.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||||
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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
@@ -96,8 +96,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
|||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
@@ -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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.28/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,26 +127,26 @@ 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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||||
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.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||||
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||||
@@ -154,38 +154,38 @@ github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AO
|
|||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
|
||||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -200,10 +200,10 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -211,8 +211,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -225,8 +225,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -236,8 +236,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -249,10 +249,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
@@ -261,24 +261,22 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
||||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
|
||||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
|
||||||
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-20251111163417-95abcf5c77ba h1:Ze6qXW0j37YCqZdCD2LkzVSxgEWez0cO4NUyd44DiDY=
|
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
|
||||||
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:4FLPzLA8eGAktPOTemJGDgDYRpLYwrNu4u2JtWINhnI=
|
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
//go:build !nopayments
|
|
||||||
|
|
||||||
package payments
|
|
||||||
|
|
||||||
import "github.com/stripe/stripe-go/v74"
|
|
||||||
|
|
||||||
// Available is a constant used to indicate that Stripe support is available.
|
|
||||||
// It can be disabled with the 'nopayments' build tag.
|
|
||||||
const Available = true
|
|
||||||
|
|
||||||
// SubscriptionStatus is an alias for stripe.SubscriptionStatus
|
|
||||||
type SubscriptionStatus stripe.SubscriptionStatus
|
|
||||||
|
|
||||||
// PriceRecurringInterval is an alias for stripe.PriceRecurringInterval
|
|
||||||
type PriceRecurringInterval stripe.PriceRecurringInterval
|
|
||||||
|
|
||||||
// Setup sets the Stripe secret key and disables telemetry
|
|
||||||
func Setup(stripeSecretKey string) {
|
|
||||||
stripe.EnableTelemetry = false // Whoa!
|
|
||||||
stripe.Key = stripeSecretKey
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
//go:build nopayments
|
|
||||||
|
|
||||||
package payments
|
|
||||||
|
|
||||||
// Available is a constant used to indicate that Stripe support is available.
|
|
||||||
// It can be disabled with the 'nopayments' build tag.
|
|
||||||
const Available = false
|
|
||||||
|
|
||||||
// SubscriptionStatus is a dummy type
|
|
||||||
type SubscriptionStatus string
|
|
||||||
|
|
||||||
// PriceRecurringInterval is dummy type
|
|
||||||
type PriceRecurringInterval string
|
|
||||||
|
|
||||||
// Setup is a dummy type
|
|
||||||
func Setup(stripeSecretKey string) {
|
|
||||||
// Nothing to see here
|
|
||||||
}
|
|
||||||
@@ -95,9 +95,8 @@ type Config struct {
|
|||||||
AuthFile string
|
AuthFile string
|
||||||
AuthStartupQueries string
|
AuthStartupQueries string
|
||||||
AuthDefault user.Permission
|
AuthDefault user.Permission
|
||||||
AuthUsers []*user.User
|
AuthProvisionedUsers []*user.User
|
||||||
AuthAccess map[string][]*user.Grant
|
AuthProvisionedAccess map[string][]*user.Grant
|
||||||
AuthTokens map[string][]*user.Token
|
|
||||||
AuthBcryptCost int
|
AuthBcryptCost int
|
||||||
AuthStatsQueueWriterInterval time.Duration
|
AuthStatsQueueWriterInterval time.Duration
|
||||||
AttachmentCacheDir string
|
AttachmentCacheDir string
|
||||||
@@ -162,7 +161,6 @@ type Config struct {
|
|||||||
BillingContact string
|
BillingContact string
|
||||||
EnableSignup bool // Enable creation of accounts via API and UI
|
EnableSignup bool // Enable creation of accounts via API and UI
|
||||||
EnableLogin bool
|
EnableLogin bool
|
||||||
RequireLogin bool
|
|
||||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||||
EnableMetrics bool
|
EnableMetrics bool
|
||||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||||
@@ -257,7 +255,6 @@ func NewConfig() *Config {
|
|||||||
EnableSignup: false,
|
EnableSignup: false,
|
||||||
EnableLogin: false,
|
EnableLogin: false,
|
||||||
EnableReservations: false,
|
EnableReservations: false,
|
||||||
RequireLogin: false,
|
|
||||||
AccessControlAllowOrigin: "*",
|
AccessControlAllowOrigin: "*",
|
||||||
Version: "",
|
Version: "",
|
||||||
WebPushPrivateKey: "",
|
WebPushPrivateKey: "",
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ var (
|
|||||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
||||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
||||||
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
||||||
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
|
|
||||||
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
|
|
||||||
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
||||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
priority INT NOT NULL,
|
priority INT NOT NULL,
|
||||||
tags TEXT NOT NULL,
|
tags TEXT NOT NULL,
|
||||||
click TEXT NOT NULL,
|
click TEXT NOT NULL,
|
||||||
icon TEXT NOT NULL,
|
icon TEXT NOT NULL,
|
||||||
actions TEXT NOT NULL,
|
actions TEXT NOT NULL,
|
||||||
attachment_name TEXT NOT NULL,
|
attachment_name TEXT NOT NULL,
|
||||||
attachment_type TEXT NOT NULL,
|
attachment_type TEXT NOT NULL,
|
||||||
@@ -65,6 +65,10 @@ const (
|
|||||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
|
builtinMessageCacheStartupQueries = `
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA busy_timeout = 50000; -- Wait up to 5 seconds for a lock to be released
|
||||||
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -74,30 +78,30 @@ const (
|
|||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
selectMessagesByIDQuery = `
|
selectMessagesByIDQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE mid = ?
|
WHERE mid = ?
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeQuery = `
|
selectMessagesSinceTimeQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDQuery = `
|
selectMessagesSinceIDQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND id > ? AND published = 1
|
WHERE topic = ? AND id > ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
@@ -107,10 +111,10 @@ const (
|
|||||||
WHERE topic = ? AND published = 1
|
WHERE topic = ? AND published = 1
|
||||||
ORDER BY time DESC, id DESC
|
ORDER BY time DESC, id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
@@ -284,18 +288,22 @@ type messageCache struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
queue *util.BatchingQueue[*message]
|
queue *util.BatchingQueue[*message]
|
||||||
nop bool
|
nop bool
|
||||||
mu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSqliteCache creates a SQLite file-backed cache
|
// newSqliteCache creates a SQLite file-backed cache
|
||||||
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
||||||
|
// Parse the filename
|
||||||
|
file, datasource, err := parseSqliteFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse cache database filename %s: %w", filename, err)
|
||||||
|
}
|
||||||
// Check the parent directory of the database file (makes for friendly error messages)
|
// Check the parent directory of the database file (makes for friendly error messages)
|
||||||
parentDir := filepath.Dir(filename)
|
parentDir := filepath.Dir(filename)
|
||||||
if !util.FileExists(parentDir) {
|
if !util.FileExists(parentDir) {
|
||||||
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
|
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
|
||||||
}
|
}
|
||||||
// Open database
|
// Open database
|
||||||
db, err := sql.Open("sqlite3", filename)
|
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_busy_timeout=50000", filename))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -349,8 +357,6 @@ func (c *messageCache) AddMessage(m *message) error {
|
|||||||
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
|
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
|
||||||
// SQLite's busy_timeout is exceeded before erroring out.
|
// SQLite's busy_timeout is exceeded before erroring out.
|
||||||
func (c *messageCache) addMessages(ms []*message) error {
|
func (c *messageCache) addMessages(ms []*message) error {
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.nop {
|
if c.nop {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -532,8 +538,6 @@ func (c *messageCache) Message(id string) (*message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MarkPublished(m *message) error {
|
func (c *messageCache) MarkPublished(m *message) error {
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -579,8 +583,6 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) DeleteMessages(ids ...string) error {
|
func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -595,8 +597,6 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -631,8 +631,6 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -778,8 +776,6 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) UpdateStats(messages int64) error {
|
func (c *messageCache) UpdateStats(messages int64) error {
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
_, err := c.db.Exec(updateStatsQuery, messages)
|
_, err := c.db.Exec(updateStatsQuery, messages)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -803,8 +799,21 @@ func (c *messageCache) Close() error {
|
|||||||
return c.db.Close()
|
return c.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseSqliteFile(filename string) (file string, datasource string, err error) {
|
||||||
|
f, err := url.Parse(filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("cannot parse cache database filename %s: %w", filename, err)
|
||||||
|
} else if f.Scheme != "file" {
|
||||||
|
return f.Path, filename, nil
|
||||||
|
}
|
||||||
|
return filename, filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
||||||
// Run startup queries
|
// Run startup queries
|
||||||
|
if _, err := db.Exec(builtinMessageCacheStartupQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if startupQueries != "" {
|
if startupQueries != "" {
|
||||||
if _, err := db.Exec(startupQueries); err != nil {
|
if _, err := db.Exec(startupQueries); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -102,7 +103,7 @@ func TestMemCache_MessagesLock(t *testing.T) {
|
|||||||
|
|
||||||
func testCacheMessagesLock(t *testing.T, c *messageCache) {
|
func testCacheMessagesLock(t *testing.T, c *messageCache) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < 5000; i++ {
|
for i := 0; i < 3000; i++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
|
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
|
||||||
@@ -707,6 +708,35 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
|||||||
require.Nil(t, rows.Close())
|
require.Nil(t, rows.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestURL(t *testing.T) {
|
||||||
|
u, _ := url.Parse("file:mem?_busy_timeout=1000&_journal_mode=WAL&_synchronous=normal&_temp_store=memory")
|
||||||
|
fmt.Printf("opaque: %+v\n", u.Opaque)
|
||||||
|
fmt.Printf("scheme: %+v\n", u.Scheme)
|
||||||
|
fmt.Printf("host: %+v\n", u.Host)
|
||||||
|
fmt.Printf("path: %+v\n", u.Path)
|
||||||
|
fmt.Printf("raw path: %+v\n", u.RawPath)
|
||||||
|
fmt.Printf("raw query: %+v\n", u.RawQuery)
|
||||||
|
fmt.Printf("query: %+v\n", u.Query())
|
||||||
|
fmt.Println("----------")
|
||||||
|
u, _ = url.Parse("myfile.db")
|
||||||
|
fmt.Printf("opaque: %+v\n", u.Opaque)
|
||||||
|
fmt.Printf("scheme: %+v\n", u.Scheme)
|
||||||
|
fmt.Printf("host: %+v\n", u.Host)
|
||||||
|
fmt.Printf("path: %+v\n", u.Path)
|
||||||
|
fmt.Printf("raw path: %+v\n", u.RawPath)
|
||||||
|
fmt.Printf("raw query: %+v\n", u.RawQuery)
|
||||||
|
fmt.Printf("query: %+v\n", u.Query())
|
||||||
|
fmt.Println("----------")
|
||||||
|
u, _ = url.Parse("htttps://abc.com/myfile.db")
|
||||||
|
fmt.Printf("opaque: %+v\n", u.Opaque)
|
||||||
|
fmt.Printf("scheme: %+v\n", u.Scheme)
|
||||||
|
fmt.Printf("host: %+v\n", u.Host)
|
||||||
|
fmt.Printf("path: %+v\n", u.Path)
|
||||||
|
fmt.Printf("raw path: %+v\n", u.RawPath)
|
||||||
|
fmt.Printf("raw query: %+v\n", u.RawQuery)
|
||||||
|
fmt.Printf("query: %+v\n", u.Query())
|
||||||
|
|
||||||
|
}
|
||||||
func TestMemCache_NopCache(t *testing.T) {
|
func TestMemCache_NopCache(t *testing.T) {
|
||||||
c, _ := newNopCache()
|
c, _ := newNopCache()
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -31,9 +32,7 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/payments"
|
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"heckel.io/ntfy/v2/util/sprig"
|
"heckel.io/ntfy/v2/util/sprig"
|
||||||
@@ -166,7 +165,7 @@ func New(conf *Config) (*Server, error) {
|
|||||||
mailer = &smtpSender{config: conf}
|
mailer = &smtpSender{config: conf}
|
||||||
}
|
}
|
||||||
var stripe stripeAPI
|
var stripe stripeAPI
|
||||||
if payments.Available && conf.StripeSecretKey != "" {
|
if conf.StripeSecretKey != "" {
|
||||||
stripe = newStripeAPI()
|
stripe = newStripeAPI()
|
||||||
}
|
}
|
||||||
messageCache, err := createMessageCache(conf)
|
messageCache, err := createMessageCache(conf)
|
||||||
@@ -202,9 +201,8 @@ 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
|
||||||
Users: conf.AuthUsers,
|
ProvisionUsers: conf.AuthProvisionedUsers,
|
||||||
Access: conf.AuthAccess,
|
ProvisionAccess: conf.AuthProvisionedAccess,
|
||||||
Tokens: conf.AuthTokens,
|
|
||||||
BcryptCost: conf.AuthBcryptCost,
|
BcryptCost: conf.AuthBcryptCost,
|
||||||
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
||||||
}
|
}
|
||||||
@@ -600,7 +598,6 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
BaseURL: "", // Will translate to window.location.origin
|
BaseURL: "", // Will translate to window.location.origin
|
||||||
AppRoot: s.config.WebRoot,
|
AppRoot: s.config.WebRoot,
|
||||||
EnableLogin: s.config.EnableLogin,
|
EnableLogin: s.config.EnableLogin,
|
||||||
RequireLogin: s.config.RequireLogin,
|
|
||||||
EnableSignup: s.config.EnableSignup,
|
EnableSignup: s.config.EnableSignup,
|
||||||
EnablePayments: s.config.StripeSecretKey != "",
|
EnablePayments: s.config.StripeSecretKey != "",
|
||||||
EnableCalls: s.config.TwilioAccount != "",
|
EnableCalls: s.config.TwilioAccount != "",
|
||||||
@@ -1004,12 +1001,7 @@ 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
|
||||||
}
|
}
|
||||||
template = templateMode(readParam(r, "x-template", "template", "tpl"))
|
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1043,7 @@ 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" {
|
||||||
@@ -1136,8 +1129,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 template.FileMode() {
|
if templateName := template.Name(); templateName != "" {
|
||||||
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
|
if err := s.renderTemplateFromFile(m, templateName, peekedBody); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1215,7 +1208,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(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
return strings.TrimSpace(buf.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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,14 +82,10 @@
|
|||||||
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
|
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
|
||||||
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
|
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
|
||||||
# WAL mode. This is similar to cache-startup-queries. See above for details.
|
# WAL mode. This is similar to cache-startup-queries. See above for details.
|
||||||
# - auth-users is a list of users that are automatically created when the server starts.
|
# - auth-provision-users is a list of users that are automatically created when the server starts.
|
||||||
# Each entry is in the format "<username>:<password-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
|
# Each entry is in the format "<username>:<bcrypt-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
|
||||||
# Use 'ntfy user hash' to generate the password hash from a password.
|
# - auth-provision-access is a list of access control entries that are automatically created when the server starts.
|
||||||
# - 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
|
||||||
@@ -102,9 +98,8 @@
|
|||||||
# auth-file: <filename>
|
# auth-file: <filename>
|
||||||
# auth-default-access: "read-write"
|
# auth-default-access: "read-write"
|
||||||
# auth-startup-queries:
|
# auth-startup-queries:
|
||||||
# auth-users:
|
# auth-provision-users:
|
||||||
# auth-access:
|
# auth-provision-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.
|
||||||
@@ -258,11 +253,9 @@
|
|||||||
#
|
#
|
||||||
# - enable-signup allows users to sign up via the web app, or API
|
# - enable-signup allows users to sign up via the web app, or API
|
||||||
# - enable-login allows users to log in via the web app, or API
|
# - enable-login allows users to log in via the web app, or API
|
||||||
# - require-login redirects users to the login page if they are not logged in (disallows web app access without login)
|
|
||||||
# - enable-reservations allows users to reserve topics (if their tier allows it)
|
# - enable-reservations allows users to reserve topics (if their tier allows it)
|
||||||
#
|
#
|
||||||
# enable-signup: false
|
# enable-signup: false
|
||||||
# require-login: false
|
|
||||||
# enable-login: false
|
# enable-login: false
|
||||||
# enable-reservations: false
|
# enable-reservations: false
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ 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
|
||||||
@@ -140,12 +139,11 @@ 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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,12 +174,6 @@ 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)
|
||||||
@@ -216,9 +208,6 @@ 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())
|
||||||
@@ -245,7 +234,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(), false)
|
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -285,9 +274,6 @@ 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{
|
||||||
@@ -310,9 +296,6 @@ 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(), false)
|
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
|
||||||
|
|
||||||
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,11 +251,7 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_ChangePassword(t *testing.T) {
|
func TestAccount_ChangePassword(t *testing.T) {
|
||||||
conf := newTestConfigWithAuthFile(t)
|
s := newTestServer(t, 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))
|
||||||
@@ -285,12 +281,6 @@ 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) {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build !nofirebase
|
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,10 +14,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// FirebaseAvailable is a constant used to indicate that Firebase support is available.
|
|
||||||
// It can be disabled with the 'nofirebase' build tag.
|
|
||||||
FirebaseAvailable = true
|
|
||||||
|
|
||||||
fcmMessageLimit = 4000
|
fcmMessageLimit = 4000
|
||||||
fcmApnsBodyMessageLimit = 100
|
fcmApnsBodyMessageLimit = 100
|
||||||
)
|
)
|
||||||
@@ -79,7 +73,7 @@ type firebaseSenderImpl struct {
|
|||||||
client *messaging.Client
|
client *messaging.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) {
|
||||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
//go:build nofirebase
|
|
||||||
|
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"heckel.io/ntfy/v2/user"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// FirebaseAvailable is a constant used to indicate that Firebase support is available.
|
|
||||||
// It can be disabled with the 'nofirebase' build tag.
|
|
||||||
FirebaseAvailable = false
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errFirebaseNotAvailable = errors.New("Firebase not available")
|
|
||||||
errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase")
|
|
||||||
)
|
|
||||||
|
|
||||||
type firebaseClient struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
|
||||||
return errFirebaseNotAvailable
|
|
||||||
}
|
|
||||||
|
|
||||||
type firebaseSender interface {
|
|
||||||
Send(m string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClient {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
|
||||||
return nil, errFirebaseNotAvailable
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build !nofirebase
|
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build !nopayments
|
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,7 +12,6 @@ import (
|
|||||||
"github.com/stripe/stripe-go/v74/subscription"
|
"github.com/stripe/stripe-go/v74/subscription"
|
||||||
"github.com/stripe/stripe-go/v74/webhook"
|
"github.com/stripe/stripe-go/v74/webhook"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/payments"
|
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -25,7 +22,7 @@ import (
|
|||||||
|
|
||||||
// Payments in ntfy are done via Stripe.
|
// Payments in ntfy are done via Stripe.
|
||||||
//
|
//
|
||||||
// Pretty much all payments-related things are in this file. The following processes
|
// Pretty much all payments related things are in this file. The following processes
|
||||||
// handle payments:
|
// handle payments:
|
||||||
//
|
//
|
||||||
// - Checkout:
|
// - Checkout:
|
||||||
@@ -467,8 +464,8 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
|
|||||||
billing := &user.Billing{
|
billing := &user.Billing{
|
||||||
StripeCustomerID: customerID,
|
StripeCustomerID: customerID,
|
||||||
StripeSubscriptionID: subscriptionID,
|
StripeSubscriptionID: subscriptionID,
|
||||||
StripeSubscriptionStatus: payments.SubscriptionStatus(status),
|
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
|
||||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(interval),
|
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
|
||||||
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
|
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
//go:build nopayments
|
|
||||||
|
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type stripeAPI interface {
|
|
||||||
CancelSubscription(id string) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newStripeAPI() stripeAPI {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) fetchStripePrices() (map[string]int64, error) {
|
|
||||||
return nil, errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build !nopayments
|
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,7 +6,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"heckel.io/ntfy/v2/payments"
|
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -348,8 +345,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
|||||||
require.Nil(t, u.Tier)
|
require.Nil(t, u.Tier)
|
||||||
require.Equal(t, "", u.Billing.StripeCustomerID)
|
require.Equal(t, "", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||||
require.Equal(t, payments.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||||
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
|
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
|
||||||
@@ -365,8 +362,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
|||||||
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus)
|
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
|
||||||
require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval)
|
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval)
|
||||||
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||||
require.Equal(t, int64(0), u.Stats.Messages)
|
require.Equal(t, int64(0), u.Stats.Messages)
|
||||||
@@ -476,8 +473,8 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
|||||||
billing := &user.Billing{
|
billing := &user.Billing{
|
||||||
StripeCustomerID: "acct_5555",
|
StripeCustomerID: "acct_5555",
|
||||||
StripeSubscriptionID: "sub_1234",
|
StripeSubscriptionID: "sub_1234",
|
||||||
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
||||||
}
|
}
|
||||||
@@ -520,10 +517,10 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
|||||||
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||||
require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalYear), u.Billing.StripeSubscriptionInterval) // Not "month"
|
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month"
|
||||||
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||||
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||||
|
|
||||||
// Verify that reservations were deleted
|
// Verify that reservations were deleted
|
||||||
r, err := s.userManager.Reservations("phil")
|
r, err := s.userManager.Reservations("phil")
|
||||||
@@ -583,8 +580,8 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
|||||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
|
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
|
||||||
StripeCustomerID: "acct_5555",
|
StripeCustomerID: "acct_5555",
|
||||||
StripeSubscriptionID: "sub_1234",
|
StripeSubscriptionID: "sub_1234",
|
||||||
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||||
}))
|
}))
|
||||||
@@ -601,7 +598,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
|||||||
require.Nil(t, u.Tier)
|
require.Nil(t, u.Tier)
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/SherClockHolmes/webpush-go"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -280,6 +281,30 @@ func TestServer_WebEnabled(t *testing.T) {
|
|||||||
rr = request(t, s2, "GET", "/app.html", "", nil)
|
rr = request(t, s2, "GET", "/app.html", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_WebPushEnabled(t *testing.T) {
|
||||||
|
conf := newTestConfig(t)
|
||||||
|
conf.WebRoot = "" // Disable web app
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
||||||
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|
||||||
|
conf2 := newTestConfig(t)
|
||||||
|
s2 := newTestServer(t, conf2)
|
||||||
|
|
||||||
|
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
||||||
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|
||||||
|
conf3 := newTestConfigWithWebPush(t)
|
||||||
|
s3 := newTestServer(t, conf3)
|
||||||
|
|
||||||
|
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.AttachmentCacheDir = "" // Disable attachments
|
c.AttachmentCacheDir = "" // Disable attachments
|
||||||
@@ -3044,61 +3069,6 @@ 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
|
||||||
@@ -3232,6 +3202,17 @@ func newTestConfigWithAuthFile(t *testing.T) *Config {
|
|||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestConfigWithWebPush(t *testing.T) *Config {
|
||||||
|
conf := newTestConfig(t)
|
||||||
|
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
||||||
|
require.Nil(t, err)
|
||||||
|
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
||||||
|
conf.WebPushEmailAddress = "testing@example.com"
|
||||||
|
conf.WebPushPrivateKey = privateKey
|
||||||
|
conf.WebPushPublicKey = publicKey
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T, config *Config) *Server {
|
func newTestServer(t *testing.T, config *Config) *Server {
|
||||||
server, err := New(config)
|
server, err := New(config)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build !nowebpush
|
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,10 +13,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// WebPushAvailable is a constant used to indicate that WebPush support is available.
|
|
||||||
// It can be disabled with the 'nowebpush' build tag.
|
|
||||||
WebPushAvailable = true
|
|
||||||
|
|
||||||
webPushTopicSubscribeLimit = 50
|
webPushTopicSubscribeLimit = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
//go:build nowebpush
|
|
||||||
|
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// WebPushAvailable is a constant used to indicate that WebPush support is available.
|
|
||||||
// It can be disabled with the 'nowebpush' build tag.
|
|
||||||
WebPushAvailable = false
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
|
||||||
// Nothing to see here
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) pruneAndNotifyWebPushSubscriptions() {
|
|
||||||
// Nothing to see here
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
//go:build !nowebpush
|
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/SherClockHolmes/webpush-go"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -13,7 +10,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -24,28 +20,6 @@ const (
|
|||||||
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
|
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServer_WebPush_Enabled(t *testing.T) {
|
|
||||||
conf := newTestConfig(t)
|
|
||||||
conf.WebRoot = "" // Disable web app
|
|
||||||
s := newTestServer(t, conf)
|
|
||||||
|
|
||||||
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
|
||||||
require.Equal(t, 404, rr.Code)
|
|
||||||
|
|
||||||
conf2 := newTestConfig(t)
|
|
||||||
s2 := newTestServer(t, conf2)
|
|
||||||
|
|
||||||
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
|
||||||
require.Equal(t, 404, rr.Code)
|
|
||||||
|
|
||||||
conf3 := newTestConfigWithWebPush(t)
|
|
||||||
s3 := newTestServer(t, conf3)
|
|
||||||
|
|
||||||
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
|
||||||
|
|
||||||
}
|
|
||||||
func TestServer_WebPush_Disabled(t *testing.T) {
|
func TestServer_WebPush_Disabled(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -280,14 +254,3 @@ func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLen
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Len(t, subs, expectedLength)
|
require.Len(t, subs, expectedLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestConfigWithWebPush(t *testing.T) *Config {
|
|
||||||
conf := newTestConfig(t)
|
|
||||||
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
|
||||||
require.Nil(t, err)
|
|
||||||
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
|
||||||
conf.WebPushEmailAddress = "testing@example.com"
|
|
||||||
conf.WebPushPrivateKey = privateKey
|
|
||||||
conf.WebPushPublicKey = publicKey
|
|
||||||
return conf
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -245,46 +245,19 @@ 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 != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// InlineMode returns true if inline-templating mode is enabled
|
func (t templateMode) Name() string {
|
||||||
func (t templateMode) InlineMode() bool {
|
if isBoolValue(string(t)) {
|
||||||
return t.Enabled() && isBoolValue(string(t))
|
return ""
|
||||||
}
|
|
||||||
|
|
||||||
// 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 ""
|
return string(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"`
|
||||||
@@ -360,12 +333,11 @@ 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 {
|
||||||
@@ -427,7 +399,6 @@ 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"`
|
||||||
@@ -449,7 +420,6 @@ type apiConfigResponse struct {
|
|||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
AppRoot string `json:"app_root"`
|
AppRoot string `json:"app_root"`
|
||||||
EnableLogin bool `json:"enable_login"`
|
EnableLogin bool `json:"enable_login"`
|
||||||
RequireLogin bool `json:"require_login"`
|
|
||||||
EnableSignup bool `json:"enable_signup"`
|
EnableSignup bool `json:"enable_signup"`
|
||||||
EnablePayments bool `json:"enable_payments"`
|
EnablePayments bool `json:"enable_payments"`
|
||||||
EnableCalls bool `json:"enable_calls"`
|
EnableCalls bool `json:"enable_calls"`
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const (
|
|||||||
);
|
);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
builtinStartupQueries = `
|
builtinWebPushStartupQueries = `
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
|
|||||||
if _, err := db.Exec(startupQueries); err != nil {
|
if _, err := db.Exec(startupQueries); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(builtinStartupQueries); err != nil {
|
if _, err := db.Exec(builtinWebPushStartupQueries); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
436
user/manager.go
436
user/manager.go
@@ -7,13 +7,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mattn/go-sqlite3"
|
"github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/payments"
|
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -112,11 +111,9 @@ 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,
|
||||||
@@ -184,17 +181,15 @@ const (
|
|||||||
ELSE 2
|
ELSE 2
|
||||||
END, user
|
END, user
|
||||||
`
|
`
|
||||||
selectUserCountQuery = `SELECT COUNT(*) FROM user`
|
selectUserCountQuery = `SELECT COUNT(*) FROM user`
|
||||||
selectUserIDFromUsernameQuery = `SELECT id FROM user WHERE user = ?`
|
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||||
updateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?`
|
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
|
||||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
|
||||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
|
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
||||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
|
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
||||||
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||||
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)
|
||||||
@@ -224,7 +219,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 = `
|
||||||
@@ -259,24 +254,17 @@ 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, provisioned FROM user_token WHERE user_id = ?`
|
selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?`
|
||||||
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?`
|
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?`
|
||||||
selectAllProvisionedTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1`
|
insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
upsertTokenQuery = `
|
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
||||||
INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned)
|
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
|
||||||
ON CONFLICT (user_id, token)
|
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
||||||
DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned;
|
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
||||||
`
|
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
||||||
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
deleteExcessTokensQuery = `
|
||||||
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 (
|
||||||
@@ -481,7 +469,7 @@ const (
|
|||||||
role,
|
role,
|
||||||
prefs,
|
prefs,
|
||||||
sync_topic,
|
sync_topic,
|
||||||
0, -- provisioned
|
0,
|
||||||
stats_messages,
|
stats_messages,
|
||||||
stats_emails,
|
stats_emails,
|
||||||
stats_calls,
|
stats_calls,
|
||||||
@@ -491,8 +479,7 @@ const (
|
|||||||
stripe_subscription_interval,
|
stripe_subscription_interval,
|
||||||
stripe_subscription_paid_until,
|
stripe_subscription_paid_until,
|
||||||
stripe_subscription_cancel_at,
|
stripe_subscription_cancel_at,
|
||||||
created,
|
created, deleted
|
||||||
deleted
|
|
||||||
FROM user_old;
|
FROM user_old;
|
||||||
DROP TABLE user_old;
|
DROP TABLE user_old;
|
||||||
|
|
||||||
@@ -512,27 +499,10 @@ 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;
|
||||||
@@ -564,10 +534,9 @@ 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 // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
ProvisionEnabled bool // Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
||||||
Users []*User // Predefined users to create on startup
|
ProvisionUsers []*User // Predefined users to create on startup
|
||||||
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
|
ProvisionAccess map[string][]*Grant // Predefined access grants to create on startup
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -605,7 +574,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.maybeProvisionUsersAccessAndTokens(); err != nil {
|
if err := manager.maybeProvisionUsersAndAccess(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
go manager.asyncQueueWriter(config.QueueWriterInterval)
|
go manager.asyncQueueWriter(config.QueueWriterInterval)
|
||||||
@@ -653,15 +622,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, provisioned bool) (*Token, error) {
|
func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr) (*Token, error) {
|
||||||
return queryTx(a.db, func(tx *sql.Tx) (*Token, error) {
|
token := util.RandomLowerStringPrefix(tokenPrefix, tokenLength) // Lowercase only to support "<topic>+<token>@<domain>" email addresses
|
||||||
return a.createTokenTx(tx, userID, GenerateToken(), label, expires, origin, provisioned)
|
tx, err := a.db.Begin()
|
||||||
})
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
func (a *Manager) createTokenTx(tx *sql.Tx, userID, token, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) {
|
defer tx.Rollback()
|
||||||
access := time.Now()
|
access := time.Now()
|
||||||
if _, err := tx.Exec(upsertTokenQuery, userID, token, label, access.Unix(), origin.String(), expires.Unix(), provisioned); err != nil {
|
if _, err := tx.Exec(insertTokenQuery, userID, token, label, access.Unix(), origin.String(), expires.Unix()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := tx.Query(selectTokenCountQuery, userID)
|
rows, err := tx.Query(selectTokenCountQuery, userID)
|
||||||
@@ -683,13 +652,15 @@ func (a *Manager) createTokenTx(tx *sql.Tx, userID, token, label string, expires
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,25 +684,6 @@ 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)
|
||||||
@@ -745,11 +697,10 @@ 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, &provisioned); err != nil {
|
if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires); 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
|
||||||
@@ -759,12 +710,11 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,9 +723,6 @@ 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
|
||||||
@@ -799,35 +746,15 @@ 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 := tx.Exec(deleteTokenQuery, userID, token); err != nil {
|
if _, err := a.db.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 {
|
||||||
@@ -846,7 +773,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 errors.Is(err, ErrPhoneNumberNotFound) {
|
if err == ErrPhoneNumberNotFound {
|
||||||
break
|
break
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -996,20 +923,13 @@ 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 := a.updateTokenLastAccessTx(tx, tokenID, update.LastAccess.Unix(), update.LastOrigin.String()); err != nil {
|
if _, err := tx.Exec(updateTokenLastAccessQuery, update.LastAccess.Unix(), update.LastOrigin.String(), tokenID); 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 {
|
||||||
@@ -1066,11 +986,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 := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
|
if err := AllowedPasswordHash(hash); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hash, err = hashPassword(password, a.config.BcryptCost)
|
hash, err = a.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1089,9 +1009,6 @@ 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)
|
||||||
})
|
})
|
||||||
@@ -1242,12 +1159,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
|||||||
Calls: calls,
|
Calls: calls,
|
||||||
},
|
},
|
||||||
Billing: &Billing{
|
Billing: &Billing{
|
||||||
StripeCustomerID: stripeCustomerID.String, // May be empty
|
StripeCustomerID: stripeCustomerID.String, // May be empty
|
||||||
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
|
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
|
||||||
StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
|
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
|
||||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty
|
StripeSubscriptionInterval: stripe.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty
|
||||||
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
|
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
|
||||||
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
|
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
|
||||||
},
|
},
|
||||||
Deleted: deleted.Valid,
|
Deleted: deleted.Valid,
|
||||||
}
|
}
|
||||||
@@ -1409,36 +1326,21 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
|
|||||||
|
|
||||||
// ChangePassword changes a user's password
|
// ChangePassword changes a user's password
|
||||||
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
|
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
|
||||||
if err := a.CanChangeUser(username); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return execTx(a.db, func(tx *sql.Tx) error {
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
return a.changePasswordTx(tx, username, password, hashed)
|
return a.changePasswordTx(tx, username, password, hashed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanChangeUser checks if the user with the given username can be changed.
|
|
||||||
// This is used to prevent changes to provisioned users, which are defined in the config file.
|
|
||||||
func (a *Manager) CanChangeUser(username string) error {
|
|
||||||
user, err := a.User(username)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if user.Provisioned {
|
|
||||||
return ErrProvisionedUserChange
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
|
func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
|
||||||
var hash string
|
var hash string
|
||||||
var err error
|
var err error
|
||||||
if hashed {
|
if hashed {
|
||||||
hash = password
|
hash = password
|
||||||
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
|
if err := AllowedPasswordHash(hash); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hash, err = hashPassword(password, a.config.BcryptCost)
|
hash, err = a.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1452,9 +1354,6 @@ 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)
|
||||||
})
|
})
|
||||||
@@ -1475,15 +1374,6 @@ 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 {
|
||||||
@@ -1578,25 +1468,19 @@ 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 := tx.Exec(deleteAllAccessQuery, username)
|
_, err := a.db.Exec(deleteAllAccessQuery, username)
|
||||||
return err
|
return err
|
||||||
} else if topicPattern == "" {
|
} else if topicPattern == "" {
|
||||||
_, err := tx.Exec(deleteUserAccessQuery, username, username)
|
_, err := a.db.Exec(deleteUserAccessQuery, username, username)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := tx.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern))
|
_, err := a.db.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1702,7 +1586,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 errors.Is(err, ErrTierNotFound) {
|
if err == ErrTierNotFound {
|
||||||
break
|
break
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1763,150 +1647,79 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config.
|
func (a *Manager) maybeProvisionUsersAndAccess() error {
|
||||||
func (a *Manager) maybeProvisionUsersAccessAndTokens() error {
|
|
||||||
if !a.config.ProvisionEnabled {
|
if !a.config.ProvisionEnabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
existingUsers, err := a.Users()
|
users, err := a.Users()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
provisionUsernames := util.Map(a.config.Users, func(u *User) string {
|
provisionUsernames := util.Map(a.config.ProvisionUsers, 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 {
|
||||||
if err := a.maybeProvisionUsers(tx, provisionUsernames, existingUsers); err != nil {
|
// Remove users that are provisioned, but not in the config anymore
|
||||||
return fmt.Errorf("failed to provision users: %v", err)
|
for _, user := range users {
|
||||||
}
|
if user.Name == Everyone {
|
||||||
if err := a.maybeProvisionGrants(tx); err != nil {
|
continue
|
||||||
return fmt.Errorf("failed to provision grants: %v", err)
|
} else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) {
|
||||||
}
|
log.Tag(tag).Info("Removing previously provisioned user %s", user.Name)
|
||||||
if err := a.maybeProvisionTokens(tx, provisionUsernames); err != nil {
|
if err := a.removeUserTx(tx, user.Name); err != nil {
|
||||||
return fmt.Errorf("failed to provision tokens: %v", err)
|
return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err)
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them.
|
|
||||||
// It also removes users that are provisioned, but not in the config anymore.
|
|
||||||
func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, existingUsers []*User) error {
|
|
||||||
// Remove users that are provisioned, but not in the config anymore
|
|
||||||
for _, user := range existingUsers {
|
|
||||||
if user.Name == Everyone {
|
|
||||||
continue
|
|
||||||
} else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) {
|
|
||||||
if err := a.removeUserTx(tx, user.Name); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add or update provisioned users
|
|
||||||
for _, user := range a.config.Users {
|
|
||||||
if user.Name == Everyone {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existingUser, exists := util.Find(existingUsers, func(u *User) bool {
|
|
||||||
return u.Name == user.Name
|
|
||||||
})
|
|
||||||
if !exists {
|
|
||||||
if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) {
|
|
||||||
return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !existingUser.Provisioned {
|
|
||||||
if err := a.changeProvisionedTx(tx, user.Name, true); err != nil {
|
|
||||||
return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if existingUser.Hash != user.Hash {
|
}
|
||||||
|
// Add or update provisioned users
|
||||||
|
for _, user := range a.config.ProvisionUsers {
|
||||||
|
if user.Name == Everyone {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingUser, exists := util.Find(users, func(u *User) bool {
|
||||||
|
return u.Name == user.Name
|
||||||
|
})
|
||||||
|
if !exists {
|
||||||
|
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 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
|
||||||
return nil
|
if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != 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 _, grant := range grants {
|
for username, grants := range a.config.ProvisionAccess {
|
||||||
if err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil {
|
for _, grant := range grants {
|
||||||
return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err)
|
if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil {
|
||||||
}
|
return err
|
||||||
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,
|
||||||
@@ -2116,28 +1929,11 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -163,8 +164,8 @@ func TestManager_AddUser_And_Query(t *testing.T) {
|
|||||||
require.Nil(t, a.ChangeBilling("user", &Billing{
|
require.Nil(t, a.ChangeBilling("user", &Billing{
|
||||||
StripeCustomerID: "acct_123",
|
StripeCustomerID: "acct_123",
|
||||||
StripeSubscriptionID: "sub_123",
|
StripeSubscriptionID: "sub_123",
|
||||||
StripeSubscriptionStatus: "active",
|
StripeSubscriptionStatus: stripe.SubscriptionStatusActive,
|
||||||
StripeSubscriptionInterval: "month",
|
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||||
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
||||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||||
}))
|
}))
|
||||||
@@ -193,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(), false)
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
u, err = a.Authenticate("user", "pass")
|
u, err = a.Authenticate("user", "pass")
|
||||||
@@ -240,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(), false)
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
||||||
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))
|
||||||
}
|
}
|
||||||
@@ -522,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(), false)
|
token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||||
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)
|
||||||
@@ -585,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(), false)
|
token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||||
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(), false)
|
token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||||
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)
|
||||||
@@ -637,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(), false)
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
|
|
||||||
@@ -667,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(), false)
|
token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||||
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(), false)
|
token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
|
||||||
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)
|
||||||
@@ -681,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(), false)
|
token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||||
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)
|
||||||
@@ -794,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(), false)
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Queue token update
|
// Queue token update
|
||||||
@@ -1101,21 +1102,16 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
|
|||||||
Filename: f,
|
Filename: f,
|
||||||
DefaultAccess: PermissionReadWrite,
|
DefaultAccess: PermissionReadWrite,
|
||||||
ProvisionEnabled: true,
|
ProvisionEnabled: true,
|
||||||
Users: []*User{
|
ProvisionUsers: []*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},
|
||||||
},
|
},
|
||||||
Access: map[string][]*Grant{
|
ProvisionAccess: 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)
|
||||||
@@ -1127,55 +1123,36 @@ 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)
|
||||||
|
|
||||||
tokens, err := a.Tokens(provisionedUserID)
|
require.Equal(t, "*", users[3].Name)
|
||||||
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.Users = []*User{
|
conf.ProvisionUsers = []*User{
|
||||||
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||||
}
|
}
|
||||||
conf.Access = map[string][]*Grant{
|
conf.ProvisionAccess = 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)
|
||||||
|
|
||||||
@@ -1183,43 +1160,30 @@ 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)
|
||||||
|
|
||||||
tokens, err = a.Tokens(provisionedUserID)
|
require.Equal(t, "*", users[2].Name)
|
||||||
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.Users = []*User{}
|
conf.ProvisionUsers = []*User{}
|
||||||
conf.Access = map[string][]*Grant{}
|
conf.ProvisionAccess = 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 all gone
|
// Check that the provisioned users are there
|
||||||
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)
|
||||||
@@ -1227,103 +1191,6 @@ 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$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
|
||||||
}
|
|
||||||
conf.Access = map[string][]*Grant{
|
|
||||||
"philuser": {
|
|
||||||
{TopicPattern: "stats", Permission: PermissionReadWrite},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
a, err = NewManager(conf)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Check that the user was "upgraded" to a provisioned user
|
|
||||||
users, err = a.Users()
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Len(t, users, 2)
|
|
||||||
require.Equal(t, "philuser", users[0].Name)
|
|
||||||
require.Equal(t, RoleUser, users[0].Role)
|
|
||||||
require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
|
|
||||||
require.True(t, users[0].Provisioned) // Updated to provisioned!
|
|
||||||
|
|
||||||
grants, err = a.Grants("philuser")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 2, len(grants))
|
|
||||||
require.Equal(t, "stats", grants[0].TopicPattern)
|
|
||||||
require.Equal(t, PermissionReadWrite, grants[0].Permission)
|
|
||||||
require.True(t, grants[0].Provisioned) // Updated to provisioned!
|
|
||||||
require.Equal(t, "food", grants[1].TopicPattern)
|
|
||||||
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
|
||||||
require.False(t, grants[1].Provisioned) // Manually added grants stay!
|
|
||||||
|
|
||||||
grants, err = a.Grants(Everyone)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Empty(t, grants)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToFromSQLWildcard(t *testing.T) {
|
func TestToFromSQLWildcard(t *testing.T) {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/payments"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -58,12 +59,11 @@ 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
|
||||||
@@ -140,8 +140,8 @@ type Stats struct {
|
|||||||
type Billing struct {
|
type Billing struct {
|
||||||
StripeCustomerID string
|
StripeCustomerID string
|
||||||
StripeSubscriptionID string
|
StripeSubscriptionID string
|
||||||
StripeSubscriptionStatus payments.SubscriptionStatus
|
StripeSubscriptionStatus stripe.SubscriptionStatus
|
||||||
StripeSubscriptionInterval payments.PriceRecurringInterval
|
StripeSubscriptionInterval stripe.PriceRecurringInterval
|
||||||
StripeSubscriptionPaidUntil time.Time
|
StripeSubscriptionPaidUntil time.Time
|
||||||
StripeSubscriptionCancelAt time.Time
|
StripeSubscriptionCancelAt time.Time
|
||||||
}
|
}
|
||||||
@@ -242,20 +242,57 @@ 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 must 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")
|
||||||
ErrPasswordHashWeak = errors.New("password hash too weak, use 'ntfy user hash' to generate")
|
ErrTierNotFound = errors.New("tier not found")
|
||||||
ErrTierNotFound = errors.New("tier not found")
|
ErrTokenNotFound = errors.New("token not found")
|
||||||
ErrTokenNotFound = errors.New("token not found")
|
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
||||||
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
|
||||||
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
|
|
||||||
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
|
|
||||||
)
|
)
|
||||||
|
|||||||
79
user/util.go
79
user/util.go
@@ -1,79 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"heckel.io/ntfy/v2/util"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
|
||||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
|
||||||
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
|
||||||
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
|
||||||
allowedTokenRegex = regexp.MustCompile(`^tk_[-_A-Za-z0-9]{29}$`) // Must be tokenLength-len(tokenPrefix)
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllowedRole returns true if the given role can be used for new users
|
|
||||||
func AllowedRole(role Role) bool {
|
|
||||||
return role == RoleUser || role == RoleAdmin
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedUsername returns true if the given username is valid
|
|
||||||
func AllowedUsername(username string) bool {
|
|
||||||
return allowedUsernameRegex.MatchString(username)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedTopic returns true if the given topic name is valid
|
|
||||||
func AllowedTopic(topic string) bool {
|
|
||||||
return allowedTopicRegex.MatchString(topic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
|
||||||
func AllowedTopicPattern(topic string) bool {
|
|
||||||
return allowedTopicPatternRegex.MatchString(topic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedTier returns true if the given tier name is valid
|
|
||||||
func AllowedTier(tier string) bool {
|
|
||||||
return allowedTierRegex.MatchString(tier)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidPasswordHash checks if the given password hash is a valid bcrypt hash
|
|
||||||
func ValidPasswordHash(hash string, minCost int) error {
|
|
||||||
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
|
|
||||||
return ErrPasswordHashInvalid
|
|
||||||
}
|
|
||||||
cost, err := bcrypt.Cost([]byte(hash))
|
|
||||||
if err != nil { // Check if the hash is valid (length, format, etc.)
|
|
||||||
return err
|
|
||||||
} else if cost < minCost {
|
|
||||||
return ErrPasswordHashWeak
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidToken returns true if the given token matches the naming convention
|
|
||||||
func ValidToken(token string) bool {
|
|
||||||
return allowedTokenRegex.MatchString(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateToken generates a new token with a prefix and a fixed length
|
|
||||||
// Lowercase only to support "<topic>+<token>@<domain>" email addresses
|
|
||||||
func GenerateToken() string {
|
|
||||||
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HashPassword hashes the given password using bcrypt with the configured cost
|
|
||||||
func HashPassword(password string) (string, error) {
|
|
||||||
return hashPassword(password, DefaultUserPasswordBcryptCost)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashPassword(password string, cost int) (string, error) {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(hash), nil
|
|
||||||
}
|
|
||||||
1064
web/package-lock.json
generated
1064
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ var config = {
|
|||||||
base_url: window.location.origin, // Change to test against a different server
|
base_url: window.location.origin, // Change to test against a different server
|
||||||
app_root: "/",
|
app_root: "/",
|
||||||
enable_login: true,
|
enable_login: true,
|
||||||
require_login: false,
|
|
||||||
enable_signup: true,
|
enable_signup: true,
|
||||||
enable_payments: false,
|
enable_payments: false,
|
||||||
enable_reservations: true,
|
enable_reservations: true,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"publish_dialog_chip_email_label": "Препращане към ел. поща",
|
"publish_dialog_chip_email_label": "Препращане към ел. поща",
|
||||||
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
|
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
|
||||||
"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": "Промяна на темата",
|
||||||
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
|
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
|
||||||
"publish_dialog_button_cancel": "Отказ",
|
"publish_dialog_button_cancel": "Отказ",
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"subscribe_dialog_login_button_login": "Вход",
|
"subscribe_dialog_login_button_login": "Вход",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
|
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
|
||||||
"prefs_appearance_title": "Външен вид",
|
"prefs_appearance_title": "Външен вид",
|
||||||
"publish_dialog_delay_placeholder": "Отложено изпращане, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
"publish_dialog_delay_placeholder": "Отлагане на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
||||||
"prefs_notifications_delete_after_one_week": "След една седмица",
|
"prefs_notifications_delete_after_one_week": "След една седмица",
|
||||||
"prefs_users_title": "Управление на потребители",
|
"prefs_users_title": "Управление на потребители",
|
||||||
"prefs_users_table_base_url_header": "Адрес на услугата",
|
"prefs_users_table_base_url_header": "Адрес на услугата",
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
"publish_dialog_topic_reset": "Нулиране на тема",
|
"publish_dialog_topic_reset": "Нулиране на тема",
|
||||||
"publish_dialog_click_reset": "Премахване на адрес",
|
"publish_dialog_click_reset": "Премахване на адрес",
|
||||||
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
||||||
"publish_dialog_delay_reset": "Премахва отложеното на изпращане",
|
"publish_dialog_delay_reset": "Премахва отлагането на изпращането",
|
||||||
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
||||||
"emoji_picker_search_clear": "Изчистване на търсенето",
|
"emoji_picker_search_clear": "Изчистване на търсенето",
|
||||||
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
"account_delete_dialog_button_cancel": "Отказ",
|
"account_delete_dialog_button_cancel": "Отказ",
|
||||||
"account_upgrade_dialog_interval_monthly": "Месечно",
|
"account_upgrade_dialog_interval_monthly": "Месечно",
|
||||||
"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_tokens_dialog_button_cancel": "Отказ",
|
"account_tokens_dialog_button_cancel": "Отказ",
|
||||||
"account_delete_title": "Премахване на профила",
|
"account_delete_title": "Премахване на профила",
|
||||||
"account_upgrade_dialog_title": "Промяна нивото на профила",
|
"account_upgrade_dialog_title": "Промяна нивото на профила",
|
||||||
|
|||||||
@@ -212,7 +212,6 @@
|
|||||||
"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",
|
||||||
@@ -292,7 +291,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"signup_form_toggle_password_visibility": "Vaheta salasõna nähtavust",
|
"signup_form_toggle_password_visibility": "Vaheta salasõna nähtavust",
|
||||||
"action_bar_account": "Kasutajakonto",
|
"action_bar_account": "Kasutajakonto",
|
||||||
"action_bar_sign_in": "Logi sisse",
|
"action_bar_sign_in": "Logi sisse",
|
||||||
"nav_button_documentation": "Juhendid ja teave",
|
"nav_button_documentation": "Dokumentatsioon",
|
||||||
"action_bar_profile_title": "Profiil",
|
"action_bar_profile_title": "Profiil",
|
||||||
"action_bar_profile_settings": "Seadistused",
|
"action_bar_profile_settings": "Seadistused",
|
||||||
"action_bar_sign_up": "Liitu",
|
"action_bar_sign_up": "Liitu",
|
||||||
@@ -53,8 +53,8 @@
|
|||||||
"account_tokens_table_token_header": "Tunnusluba",
|
"account_tokens_table_token_header": "Tunnusluba",
|
||||||
"account_tokens_table_last_origin_tooltip": "IP-aadressilt {{ip}}, klõpsi täpsema teabe nägemiseks",
|
"account_tokens_table_last_origin_tooltip": "IP-aadressilt {{ip}}, klõpsi täpsema teabe nägemiseks",
|
||||||
"action_bar_reservation_add": "Reserveeri teema",
|
"action_bar_reservation_add": "Reserveeri teema",
|
||||||
"action_bar_reservation_edit": "Muuda reserveerimist",
|
"action_bar_reservation_edit": "Muuda reserveeringut",
|
||||||
"action_bar_reservation_delete": "Eemalda reserveerimine",
|
"action_bar_reservation_delete": "Eemalda reserveering",
|
||||||
"action_bar_reservation_limit_reached": "Ülempiir on käes",
|
"action_bar_reservation_limit_reached": "Ülempiir on käes",
|
||||||
"action_bar_send_test_notification": "Saata testteavitus",
|
"action_bar_send_test_notification": "Saata testteavitus",
|
||||||
"action_bar_clear_notifications": "Kustuta kõik teavitused",
|
"action_bar_clear_notifications": "Kustuta kõik teavitused",
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"account_usage_unlimited": "Piiramatu",
|
"account_usage_unlimited": "Piiramatu",
|
||||||
"prefs_notifications_delete_after_never": "Mitte kunagi",
|
"prefs_notifications_delete_after_never": "Mitte kunagi",
|
||||||
"account_upgrade_dialog_interval_monthly": "Iga kuu",
|
"account_upgrade_dialog_interval_monthly": "Iga kuu",
|
||||||
"account_upgrade_dialog_tier_price_per_month": "kuus",
|
"account_upgrade_dialog_tier_price_per_month": "kuu",
|
||||||
"prefs_notifications_web_push_disabled": "Pole kasutusel",
|
"prefs_notifications_web_push_disabled": "Pole kasutusel",
|
||||||
"prefs_appearance_title": "Välimus",
|
"prefs_appearance_title": "Välimus",
|
||||||
"prefs_appearance_language_title": "Keel",
|
"prefs_appearance_language_title": "Keel",
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
"notifications_loading": "Laadin teavitusi…",
|
"notifications_loading": "Laadin teavitusi…",
|
||||||
"publish_dialog_title_topic": "Avalda teemas {{topic}}",
|
"publish_dialog_title_topic": "Avalda teemas {{topic}}",
|
||||||
"publish_dialog_progress_uploading_detail": "Üleslaadimisel {{loaded}}/{{total}} ({{percent}}%) …",
|
"publish_dialog_progress_uploading_detail": "Üleslaadimisel {{loaded}}/{{total}} ({{percent}}%) …",
|
||||||
"publish_dialog_topic_placeholder": "Teema nimi, nt. kadri_kiirteated",
|
"publish_dialog_topic_placeholder": "Teema nimi, nt. kati_teavitused",
|
||||||
"publish_dialog_title_placeholder": "Teavituse pealkiri, nt. Andmeruumi teavitus",
|
"publish_dialog_title_placeholder": "Teavituse pealkiri, nt. Andmeruumi teavitus",
|
||||||
"publish_dialog_message_placeholder": "Siia sisesta sõnum",
|
"publish_dialog_message_placeholder": "Siia sisesta sõnum",
|
||||||
"notifications_none_for_any_title": "Sa pole veel saanud ühtegi teavitust.",
|
"notifications_none_for_any_title": "Sa pole veel saanud ühtegi teavitust.",
|
||||||
@@ -270,138 +270,5 @@
|
|||||||
"account_basics_phone_numbers_dialog_number_label": "Telefoninumber",
|
"account_basics_phone_numbers_dialog_number_label": "Telefoninumber",
|
||||||
"prefs_notifications_delete_after_one_week": "Ühe nädala möödumisel",
|
"prefs_notifications_delete_after_one_week": "Ühe nädala möödumisel",
|
||||||
"prefs_notifications_delete_after_one_day": "Ühe päeva möödumisel",
|
"prefs_notifications_delete_after_one_day": "Ühe päeva möödumisel",
|
||||||
"prefs_notifications_delete_after_one_month": "Ühe kuu möödumisel",
|
"prefs_notifications_delete_after_one_month": "Ühe kuu möödumisel"
|
||||||
"publish_dialog_attached_file_title": "Manustatud fail:",
|
|
||||||
"publish_dialog_attached_file_filename_placeholder": "Manuse faili nimi",
|
|
||||||
"publish_dialog_attached_file_remove": "Eemalda manustatud fail",
|
|
||||||
"publish_dialog_drop_file_here": "Lohista fail siia",
|
|
||||||
"emoji_picker_search_placeholder": "Otsi emojit",
|
|
||||||
"publish_dialog_checkbox_publish_another": "Avalda veel midagi",
|
|
||||||
"emoji_picker_search_clear": "Tühjenda otsing",
|
|
||||||
"account_usage_reservations_title": "Reserveeritud teemad",
|
|
||||||
"account_usage_reservations_none": "Sellel kasutajakontol pole reserveeritud teemasid",
|
|
||||||
"account_usage_attachment_storage_title": "Manuste andmeruum",
|
|
||||||
"account_usage_calls_none": "Selle kasutajakontoga ei saa helistada",
|
|
||||||
"account_usage_calls_title": "Helistatud kõnesid",
|
|
||||||
"account_usage_messages_title": "Avaldatud sõnumeid",
|
|
||||||
"account_usage_emails_title": "Saadetud e-kirju",
|
|
||||||
"account_basics_tier_manage_billing_button": "Halda arveldust",
|
|
||||||
"account_basics_tier_canceled_subscription": "Sinu teenusetellimus on katkestatud ja muutub tasuta {{date}} kontoks.",
|
|
||||||
"account_basics_tier_paid_until": "Tellimus on tasutud kuni {{date}} ja kuulub automaatselt uuendamisele",
|
|
||||||
"account_basics_tier_upgrade_button": "Hakka kasutama Pro-teenust",
|
|
||||||
"account_basics_tier_payment_overdue": "Sinu arve(d) on tasumata. Palun uuenda oma maksmisviisi või vastasel juhul peame varsti sinu kasutajakonto taseme muutma madalamaks.",
|
|
||||||
"account_upgrade_dialog_tier_features_no_reservations": "Reserveeritud teemasid pole",
|
|
||||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserveeritud teemat",
|
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserveeritud teema",
|
|
||||||
"prefs_notifications_sound_title": "Teavituse heli",
|
|
||||||
"account_tokens_delete_dialog_submit_button": "Kustuta tunnusluba jäädavalt",
|
|
||||||
"account_tokens_delete_dialog_description": "Enne tunnusloa kustutamist palun kontrolli, et ükski rakendus ei kasutaks seda. <strong>Seda tegevust ei saa tagasi pöörata</strong>.",
|
|
||||||
"account_tokens_delete_dialog_title": "Kustuta ligipääsu tunnusluba",
|
|
||||||
"account_tokens_dialog_expires_never": "Tunnusluba ei aegu iialgi",
|
|
||||||
"account_tokens_dialog_expires_x_days": "Tunnusluba aegub {{days}} päeva pärast",
|
|
||||||
"account_tokens_dialog_expires_x_hours": "Tunnusluba aegub {{hours}} tunni pärast",
|
|
||||||
"account_tokens_dialog_expires_unchanged": "Jäta aegumise kuupäev muutmata",
|
|
||||||
"account_tokens_dialog_expires_label": "Tunnusluba aegub",
|
|
||||||
"account_tokens_dialog_button_update": "Uuenda tunnusluba",
|
|
||||||
"account_tokens_dialog_button_create": "Loo tunnusluba",
|
|
||||||
"prefs_users_title": "Halda kasutajaid",
|
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Kasuta muud serverit",
|
|
||||||
"subscribe_dialog_subscribe_use_another_background_info": "Teavitused muudest serveritest ei toimi, kui veebirakendus pole avatud",
|
|
||||||
"subscribe_dialog_login_description": "See teema on kaitstud salasõnaga. Tellimiseks sisesta palun kasutajanimi ja salasõna.",
|
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Teema on juba reserveeritud",
|
|
||||||
"account_delete_title": "Kustuta kasutajakonto",
|
|
||||||
"account_delete_description": "Kustuta oma kasutajakonto jäädavalt",
|
|
||||||
"account_delete_dialog_description": "Järgnevaga kustutad serverist lõplikult oma kasutajakonto ning kõik temaga seotud andmed. Peale kustutamist pole kasutajanimi saadaval 7 päeva jooksul. Kui sa tõesti soovid kustutamisega jätkata, siis palun sisesta alljärgnevasse kasti oma salasõna.",
|
|
||||||
"web_push_unknown_notification_title": "Serverist saabus tundmatu teavitus",
|
|
||||||
"web_push_subscription_expiring_body": "Kui soovid, et jätkuvalt saabuks teavitused, siis ava ntfy",
|
|
||||||
"web_push_subscription_expiring_title": "Teavitused on ajutiselt peatatud",
|
|
||||||
"error_boundary_unsupported_indexeddb_title": "Veebibrauseri privaatne režiim pole toetatud",
|
|
||||||
"error_boundary_button_reload_ntfy": "Laadi ntfy uuesti",
|
|
||||||
"error_boundary_button_copy_stack_trace": "Kopeeri pinujälg",
|
|
||||||
"error_boundary_stack_trace": "Pinujälg",
|
|
||||||
"error_boundary_gathering_info": "Kogu täiendavat teavet…",
|
|
||||||
"notifications_none_for_any_description": "Teemakohaste teavituste saatmiseks tee PUT või POST meetodiga päring teema võrguaadressile. Siin on üks näide ühe sinu teemaga.",
|
|
||||||
"notifications_no_subscriptions_title": "Tundub, et sul pole veel ühtegi tellimust.",
|
|
||||||
"notifications_no_subscriptions_description": "Olemasoleva teema tellimiseks või uue loomiseks klõpsa „{{linktext}}“. Peale seda saad PUT või POST meetodiga päringuga saata sõnumeid ning neid siin vastu võtta.",
|
|
||||||
"notifications_more_details": "Lisateavet leiad <websiteLink>veebisaidist</websiteLink> või <docsLink>juhendist</docsLink>.",
|
|
||||||
"publish_dialog_details_examples_description": "Näited ja saatmisvõimaluste üksikasjaliku kirjelduse leiad <docsLink>juhendist</docsLink>.",
|
|
||||||
"account_tokens_description": "Selleks, et ei peaks ntfy API abil avaldamise ja tellimuse päringusse lisama kasutajanime ja salasõna, kasuta tunnuslubasid. Lisateavet leiad <Link>juhendist</Link>.",
|
|
||||||
"subscribe_dialog_subscribe_title": "Telli teema",
|
|
||||||
"subscribe_dialog_subscribe_description": "Teemasid ei saa salasõnaga kaitsta, seega vali teema nimi, mida pole väga lihtne ära arvata. Peale tellimuse tegemist võide kohe hakata PUT või POST päringutega sõnumeid saatma.",
|
|
||||||
"subscribe_dialog_subscribe_topic_placeholder": "Teema nimi, näiteks kadri_kiirteated",
|
|
||||||
"subscribe_dialog_error_user_not_authorized": "Kasutajal {{username}} puudub volitus",
|
|
||||||
"account_usage_of_limit": "piirangust {{limit}}",
|
|
||||||
"account_usage_limits_reset_daily": "Kasutuspiirangud lähtestatakse keskööl (UTC järgi)",
|
|
||||||
"account_basics_tier_admin_suffix_with_tier": "(tasemega {{tier}})",
|
|
||||||
"account_basics_tier_admin_suffix_no_tier": "(tase puudub)",
|
|
||||||
"account_upgrade_dialog_title": "Muuda kasutajakonto taset",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} faili kohta",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} kõnet päevas",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} kõne päevas",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Ilma telefonikõnedeta",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "andmeruum kokku {{totalsize}}",
|
|
||||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}}aastas. Arveldatuna kord kuus.",
|
|
||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} arveldatuna kord aastas. Sa säästad {{save}}.",
|
|
||||||
"account_upgrade_dialog_button_pay_now": "Maksa nüüd ja telli",
|
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Katkesta tellimus",
|
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save": "säästa {{discount}}%",
|
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "säästa kuni {{discount}}%",
|
|
||||||
"account_upgrade_dialog_billing_contact_email": "Küsimuste puhul arvelduste kohta, palun <Link>kontakteeru meiega otse</Link>.",
|
|
||||||
"account_upgrade_dialog_billing_contact_website": "Küsimuste puhul arvelduste kohta, palun <Link>vaata meie veebisaiti</Link>.",
|
|
||||||
"account_delete_dialog_billing_warning": "Sinu kasutajakonto kustutamisel katkeb koheselt ka tellimus. Muu hulgas ei saa sa enam ligi arvelduste haldusvaatele.",
|
|
||||||
"account_upgrade_dialog_button_update_subscription": "Uuenda tellimust",
|
|
||||||
"account_tokens_title": "Tunnusload ligipääsuks",
|
|
||||||
"account_tokens_dialog_label": "Silt, näiteks „Salaradari teavitused“",
|
|
||||||
"account_usage_attachment_storage_description": "{{filesize}} faili kohta, kustutatud peale {{expiry}}",
|
|
||||||
"account_usage_cannot_create_portal_session": "Arvelduste vaate avamine ei õnnestu",
|
|
||||||
"prefs_notifications_min_priority_any": "Kõik prioriteedid",
|
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Vähetähtsad ja kõrgemad",
|
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Vaikimisi tähtsusega ja kõrgemad",
|
|
||||||
"prefs_notifications_min_priority_high_and_higher": "Väga tähtsad ja kõrgemad",
|
|
||||||
"prefs_notifications_min_priority_max_only": "Vaid kõrgeim prioriteet",
|
|
||||||
"prefs_reservations_table_everyone_deny_all": "Vaid mina saan avaldada ja tellida",
|
|
||||||
"prefs_reservations_table_everyone_read_only": "Mina saan avaldada ja tellida, kõik saavad tellida",
|
|
||||||
"prefs_reservations_table_everyone_write_only": "Mina saan avaldada ja tellida, kõik saavad avaldada",
|
|
||||||
"prefs_reservations_table_everyone_read_write": "Kõik saavad avaldada ja tellida",
|
|
||||||
"prefs_reservations_table_not_subscribed": "Pole tellitud",
|
|
||||||
"prefs_reservations_table_click_to_subscribe": "Tellimiseks klõpsi",
|
|
||||||
"prefs_reservations_dialog_title_add": "Reserveeri teema",
|
|
||||||
"prefs_reservations_dialog_title_edit": "Muuda reserveeritud teemat",
|
|
||||||
"prefs_reservations_dialog_title_delete": "Kustuta teema reserveering",
|
|
||||||
"prefs_reservations_dialog_description": "Teema reserveerimisega muutud selle omanikuks ja saad teiste jaoks määrata ligipääsuõigusi teemale.",
|
|
||||||
"reservation_delete_dialog_description": "Teema reserveerimisest loobudes annad teistele võimaluse seda reserveerida ja muutuda selle omanikuks. Sina saad otsustada, kas vanad sõnumid jäävad alles või kustutatakse.",
|
|
||||||
"reservation_delete_dialog_action_keep_title": "Säilita puhverdatud sõnumid ja manused",
|
|
||||||
"reservation_delete_dialog_action_keep_description": "Serveris puhverdatud sõnumid ja manused muutuvad avalikult nähtavaks neile, kes teavad teema nime.",
|
|
||||||
"reservation_delete_dialog_action_delete_title": "Kustuta puhverdatud sõnumid ja manused",
|
|
||||||
"reservation_delete_dialog_action_delete_description": "Puhverdatud sõnumid ja manused kustuvad jäädavalt. Seda tegevust ei saa hiljem tagasi pöörata.",
|
|
||||||
"reservation_delete_dialog_submit_button": "Kustuta reserveerimine",
|
|
||||||
"prefs_reservations_description": "Sa võid teemade nimesid reserveerida isiklikuks kasutuseks. Sellega muutud teema omanikuks ja saad määrata, kes ning mis viisil teemale ligi saab.",
|
|
||||||
"prefs_reservations_limit_reached": "Oled jõudnud reserveeritud teemade arvu ülempiirini.",
|
|
||||||
"prefs_reservations_add_button": "Lisa reserveeritud teema",
|
|
||||||
"prefs_reservations_edit_button": "Muuda ligipääsu teemale",
|
|
||||||
"prefs_reservations_delete_button": "Lähtesta ligipääs teemale",
|
|
||||||
"prefs_reservations_table": "Reserveeritud teemade tabel",
|
|
||||||
"web_push_unknown_notification_body": "Avades veebirakenduse peaksid vist tegema ntfy uuenduse",
|
|
||||||
"prefs_users_description_no_sync": "Kasutajad ja nende salasõnad pole sinu kontoga sünkroonitud.",
|
|
||||||
"error_boundary_title": "Vaat, kus lops - ntfy jooksis kokku",
|
|
||||||
"error_boundary_description": "Ilmselgelt ei peaks niimoodi juhtuma. Vabandust.<br/>Kui sul on mõni hetk aega, siis palun <githubLink>seate sellest GitHubis</githubLink> või kirjuta <discordLink>Discordis</discordLink> või <matrixLink>Matrixis</matrixLink>.",
|
|
||||||
"error_boundary_unsupported_indexeddb_description": "Meie ntfy veebirakendus vajab korralikuks toimimiseks brauseri IndexedDB funktsionaalsust, aga sinu veebibrauser seda privaatses režiimis ei toeta.<br/><br/>See on nüüd õnnetu lugu küll, aga olemuslikult pole ntfy veebirakenduse kasutamisel privaatses režiimis eriti mõtet - kõike hoitakse ju brauseri hallatavas andmekogus. Lisateavet selle kohta leiad <githubLink>GitHubist siit</githubLink>, aga saad ka teema üle meiega arutleda <discordLink>Discordis</discordLink> või <matrixLink>Matrixis</matrixLink>.",
|
|
||||||
"account_usage_basis_ip_description": "Selle kasutajakonto statistika ja kasutuspiirangud põhinevad sinu IP-aadressil ja seega võivad nad olla teistega jagatud. Siin näidatud piirangud on hinnangulised ja põhinevad üldistel päringupiirangutel.",
|
|
||||||
"prefs_notifications_web_push_enabled": "Kasutusel serveris {{server}}",
|
|
||||||
"prefs_notifications_web_push_disabled_description": "Saad teavitusi siis, kui rakendus on töös (WebSocketi abil)",
|
|
||||||
"prefs_notifications_web_push_enabled_description": "Saad teavitusi siis, kui rakendus pole töös (Web Pushi abil)",
|
|
||||||
"prefs_notifications_web_push_title": "Teavitused taustal",
|
|
||||||
"prefs_notifications_min_priority_description_max": "Näita teavitusi siis, kui prioriteet on 5 (maksimaalne)",
|
|
||||||
"prefs_notifications_min_priority_description_x_or_higher": "Näita teavitusi siis, kui prioriteet on {{number}} ({{name}}) või kõrgem",
|
|
||||||
"prefs_notifications_sound_description_none": "Teavitused ei kasuta saabumisel helimärguannet",
|
|
||||||
"prefs_notifications_sound_description_some": "Teavitused kasutavad saabumisel helimärguannet {{sound}}",
|
|
||||||
"prefs_notifications_sound_no_sound": "Helimärguanne puudub",
|
|
||||||
"prefs_notifications_sound_play": "Esita valitud helimärguannet",
|
|
||||||
"prefs_notifications_min_priority_title": "Väikseim prioriteet",
|
|
||||||
"prefs_notifications_min_priority_description_any": "Näitan kõiki teavitusi ja seejuures ei arvesta prioriteetidega",
|
|
||||||
"account_upgrade_dialog_cancel_warning": "Sellega <strong>katkestad oma tellimuse</strong> ja {{date}} muutub sinu kasutajakonto tase madalamaks. Sel kuupäeval teemade reserveeringud tühistuvad ja puhverdatud sõnumid <strong>kustutatakse serverist</strong>.",
|
|
||||||
"account_upgrade_dialog_proration_info": "<strong>Summade jagamine</strong>: Kui muudad teenusepaketti paremaks, siis pead hinnavahe <strong>maksma kohe</strong>. Kui muudad teenusepaketti madalamaks, siis hinnavahe arvelt hüvituvad mõned järgmised maksed.",
|
|
||||||
"account_upgrade_dialog_reservations_warning_one": "Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist <strong>palun esmalt kustuta vähemalt üks reserveering</strong>. Seda saad <Link>teha siin</Link>.",
|
|
||||||
"account_upgrade_dialog_reservations_warning_other": "Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist <strong>palun esmalt kustuta vähemalt {{count}} reserveeringut</strong>. Seda saad <Link>teha siin</Link>.",
|
|
||||||
"prefs_users_description": "Oma kaitstud teemade kasutajaid saad lisada ja eemaldada siin. Palun arvesta, et kasutajanimi ja salasõna on salvestatud veebibrauseri kohalikus andmeruumis."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"nav_topics_title": "Topik yang dilanggani",
|
"nav_topics_title": "Topik yang dilanggani",
|
||||||
"nav_button_subscribe": "Berlangganan ke topik",
|
"nav_button_subscribe": "Berlangganan ke topik",
|
||||||
"alert_notification_permission_required_title": "Notifikasi dinonaktifkan",
|
"alert_notification_permission_required_title": "Notifikasi dinonaktifkan",
|
||||||
"alert_notification_permission_required_description": "Berikan izin ke peramban web Anda untuk menampilkan notifikasi desktop",
|
"alert_notification_permission_required_description": "Berikan izin ke peramban untuk menampilkan notifikasi desktop.",
|
||||||
"alert_not_supported_description": "Notifikasi tidak didukung dalam peramban Anda",
|
"alert_not_supported_description": "Notifikasi tidak didukung dalam peramban Anda",
|
||||||
"notifications_attachment_open_title": "Pergi ke {{url}}",
|
"notifications_attachment_open_title": "Pergi ke {{url}}",
|
||||||
"notifications_attachment_open_button": "Buka lampiran",
|
"notifications_attachment_open_button": "Buka lampiran",
|
||||||
@@ -65,11 +65,11 @@
|
|||||||
"publish_dialog_attachment_limits_file_reached": "melebihi batasan file {{fileSizeLimit}",
|
"publish_dialog_attachment_limits_file_reached": "melebihi batasan file {{fileSizeLimit}",
|
||||||
"publish_dialog_attachment_limits_file_and_quota_reached": "melebihi batasan file dan kuota {{fileSizeLimit}}, hanya {{remainingBytes}}",
|
"publish_dialog_attachment_limits_file_and_quota_reached": "melebihi batasan file dan kuota {{fileSizeLimit}}, hanya {{remainingBytes}}",
|
||||||
"publish_dialog_attachment_limits_quota_reached": "melebihi kuota, hanya {{remainingBytes}}",
|
"publish_dialog_attachment_limits_quota_reached": "melebihi kuota, hanya {{remainingBytes}}",
|
||||||
"publish_dialog_priority_min": "Prioritas minimal",
|
"publish_dialog_priority_min": "Prioritas min.",
|
||||||
"publish_dialog_priority_low": "Prioritas rendah",
|
"publish_dialog_priority_low": "Prioritas rendah",
|
||||||
"publish_dialog_priority_default": "Prioritas bawaan",
|
"publish_dialog_priority_default": "Prioritas bawaan",
|
||||||
"publish_dialog_priority_high": "Prioritas tinggi",
|
"publish_dialog_priority_high": "Prioritas tinggi",
|
||||||
"publish_dialog_priority_max": "Prioritas maksimal",
|
"publish_dialog_priority_max": "Prioritas maks.",
|
||||||
"publish_dialog_topic_label": "Nama topik",
|
"publish_dialog_topic_label": "Nama topik",
|
||||||
"publish_dialog_message_placeholder": "Ketik sebuah pesan di sini",
|
"publish_dialog_message_placeholder": "Ketik sebuah pesan di sini",
|
||||||
"publish_dialog_click_label": "Klik URL",
|
"publish_dialog_click_label": "Klik URL",
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
"message_bar_error_publishing": "Errore durante la pubblicazione della notifica",
|
"message_bar_error_publishing": "Errore durante la pubblicazione della notifica",
|
||||||
"message_bar_show_dialog": "Mostra la finestra di dialogo di pubblicazione",
|
"message_bar_show_dialog": "Mostra la finestra di dialogo di pubblicazione",
|
||||||
"message_bar_publish": "Pubblica messaggio",
|
"message_bar_publish": "Pubblica messaggio",
|
||||||
"nav_topics_title": "Argomenti a cui si è iscritti",
|
"nav_topics_title": "Topic a cui si è iscritti",
|
||||||
"nav_button_all_notifications": "Tutte le notifiche",
|
"nav_button_all_notifications": "Tutte le notifiche",
|
||||||
"nav_button_settings": "Impostazioni",
|
"nav_button_settings": "Impostazioni",
|
||||||
"nav_button_publish_message": "Pubblica notifica",
|
"nav_button_publish_message": "Pubblica notifica",
|
||||||
"nav_button_subscribe": "Iscriviti all'argomento",
|
"nav_button_subscribe": "Iscriviti al topic",
|
||||||
"nav_button_muted": "Notifiche disattivate",
|
"nav_button_muted": "Notifiche disattivate",
|
||||||
"nav_button_connecting": "connessione",
|
"nav_button_connecting": "connessione",
|
||||||
"alert_notification_permission_required_title": "Le notifiche sono disabilitate",
|
"alert_notification_permission_required_title": "Le notifiche sono disabilitate",
|
||||||
@@ -31,17 +31,17 @@
|
|||||||
"notifications_attachment_open_title": "Vai a {{url}}",
|
"notifications_attachment_open_title": "Vai a {{url}}",
|
||||||
"notifications_attachment_open_button": "Apri allegato",
|
"notifications_attachment_open_button": "Apri allegato",
|
||||||
"notifications_attachment_link_expires": "Il collegamento scade il {{date}}",
|
"notifications_attachment_link_expires": "Il collegamento scade il {{date}}",
|
||||||
"notifications_attachment_link_expired": "collegamento per il download scaduto",
|
"notifications_attachment_link_expired": "link per il download scaduto",
|
||||||
"notifications_attachment_file_image": "file immagine",
|
"notifications_attachment_file_image": "file immagine",
|
||||||
"notifications_attachment_file_video": "file video",
|
"notifications_attachment_file_video": "file video",
|
||||||
"action_bar_toggle_mute": "Abilita/disabilita le notifiche",
|
"action_bar_toggle_mute": "Abilita/disabilita le notifiche",
|
||||||
"notifications_attachment_file_document": "altro documento",
|
"notifications_attachment_file_document": "altro documento",
|
||||||
"notifications_click_copy_url_button": "Copia collegamento",
|
"notifications_click_copy_url_button": "Copia link",
|
||||||
"notifications_click_open_button": "Apri collegamento",
|
"notifications_click_open_button": "Apri link",
|
||||||
"notifications_actions_open_url_title": "Vai a {{url}}",
|
"notifications_actions_open_url_title": "Vai a {{url}}",
|
||||||
"notifications_actions_not_supported": "Azione non supportata nell'app Web",
|
"notifications_actions_not_supported": "Azione non supportata nell'app Web",
|
||||||
"notifications_none_for_topic_title": "Non hai ancora ricevuto alcuna notifica per questo argomento.",
|
"notifications_none_for_topic_title": "Non hai ancora ricevuto alcuna notifica per questo topic.",
|
||||||
"notifications_none_for_topic_description": "Per inviare notifiche a questo argomento, è sufficiente PUT o POST all'URL dell'argomento.",
|
"notifications_none_for_topic_description": "Per inviare notifiche a questo argomento, è sufficiente PUT o POST all'URL del topic.",
|
||||||
"notifications_none_for_any_title": "Non hai ricevuto alcuna notifica.",
|
"notifications_none_for_any_title": "Non hai ricevuto alcuna notifica.",
|
||||||
"notifications_no_subscriptions_title": "Sembra che tu non abbia ancora abbonamenti.",
|
"notifications_no_subscriptions_title": "Sembra che tu non abbia ancora abbonamenti.",
|
||||||
"notifications_example": "Esempio",
|
"notifications_example": "Esempio",
|
||||||
@@ -63,9 +63,9 @@
|
|||||||
"publish_dialog_priority_max": "Max. priorità",
|
"publish_dialog_priority_max": "Max. priorità",
|
||||||
"publish_dialog_base_url_label": "URL del servizio",
|
"publish_dialog_base_url_label": "URL del servizio",
|
||||||
"publish_dialog_base_url_placeholder": "URL del servizio, ad es. https://esempio.com",
|
"publish_dialog_base_url_placeholder": "URL del servizio, ad es. https://esempio.com",
|
||||||
"publish_dialog_topic_label": "Nome argomento",
|
"publish_dialog_topic_label": "Nome topic",
|
||||||
"publish_dialog_topic_placeholder": "Nome argomento, ad es. avvisi_di_phil",
|
"publish_dialog_topic_placeholder": "Nome topic, ad es. avvisi_di_phil",
|
||||||
"publish_dialog_topic_reset": "Reimposta argomento",
|
"publish_dialog_topic_reset": "Reset topic",
|
||||||
"publish_dialog_title_label": "Titolo",
|
"publish_dialog_title_label": "Titolo",
|
||||||
"publish_dialog_title_placeholder": "Titolo della notifica, ad es. Avviso di spazio su disco",
|
"publish_dialog_title_placeholder": "Titolo della notifica, ad es. Avviso di spazio su disco",
|
||||||
"publish_dialog_message_label": "Messaggio",
|
"publish_dialog_message_label": "Messaggio",
|
||||||
@@ -97,13 +97,13 @@
|
|||||||
"publish_dialog_attached_file_remove": "Rimuovi il file allegato",
|
"publish_dialog_attached_file_remove": "Rimuovi il file allegato",
|
||||||
"publish_dialog_drop_file_here": "Trascina il file qui",
|
"publish_dialog_drop_file_here": "Trascina il file qui",
|
||||||
"emoji_picker_search_clear": "Cancella ricerca",
|
"emoji_picker_search_clear": "Cancella ricerca",
|
||||||
"subscribe_dialog_subscribe_title": "Iscriviti all'argomento",
|
"subscribe_dialog_subscribe_title": "Iscriviti al topic",
|
||||||
"subscribe_dialog_subscribe_topic_placeholder": "Nome dell'argomento, ad es. avvisi_di_phil",
|
"subscribe_dialog_subscribe_topic_placeholder": "Nome dell'argomento, ad es. avvisi_di_phil",
|
||||||
"subscribe_dialog_subscribe_base_url_label": "URL del servizio",
|
"subscribe_dialog_subscribe_base_url_label": "URL del servizio",
|
||||||
"subscribe_dialog_subscribe_button_cancel": "Annulla",
|
"subscribe_dialog_subscribe_button_cancel": "Annulla",
|
||||||
"subscribe_dialog_login_title": "Accesso richiesto",
|
"subscribe_dialog_login_title": "Accesso richiesto",
|
||||||
"subscribe_dialog_login_username_label": "Nome utente, ad es. phil",
|
"subscribe_dialog_login_username_label": "Nome utente, ad es. phil",
|
||||||
"subscribe_dialog_login_button_login": "Accesso",
|
"subscribe_dialog_login_button_login": "Login",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonimo",
|
"subscribe_dialog_error_user_anonymous": "anonimo",
|
||||||
"prefs_notifications_sound_title": "Suono di notifica",
|
"prefs_notifications_sound_title": "Suono di notifica",
|
||||||
"prefs_notifications_sound_description_some": "Le notifiche riproducono il suono {{sound}} quando arrivano",
|
"prefs_notifications_sound_description_some": "Le notifiche riproducono il suono {{sound}} quando arrivano",
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
"prefs_notifications_delete_after_one_week_description": "Le notifiche vengono eliminate automaticamente dopo una settimana",
|
"prefs_notifications_delete_after_one_week_description": "Le notifiche vengono eliminate automaticamente dopo una settimana",
|
||||||
"prefs_notifications_delete_after_one_month_description": "Le notifiche vengono eliminate automaticamente dopo un mese",
|
"prefs_notifications_delete_after_one_month_description": "Le notifiche vengono eliminate automaticamente dopo un mese",
|
||||||
"prefs_users_title": "Gestisci gli utenti",
|
"prefs_users_title": "Gestisci gli utenti",
|
||||||
"prefs_users_description": "Aggiungi/rimuovi utenti per i tuoi argomenti protetti qui. Tieni presente che nome utente e password sono memorizzati nella memoria locale del browser.",
|
"prefs_users_description": "Aggiungi/rimuovi utenti per i tuoi topic protetti qui. Tieni presente che nome utente e password sono memorizzati nella memoria locale del browser.",
|
||||||
"prefs_users_table": "Tabella utenti",
|
"prefs_users_table": "Tabella utenti",
|
||||||
"prefs_users_add_button": "Aggiungi utente",
|
"prefs_users_add_button": "Aggiungi utente",
|
||||||
"prefs_users_edit_button": "Modifica utente",
|
"prefs_users_edit_button": "Modifica utente",
|
||||||
@@ -158,16 +158,16 @@
|
|||||||
"alert_notification_permission_required_description": "Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop",
|
"alert_notification_permission_required_description": "Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop",
|
||||||
"alert_not_supported_title": "Notifiche non supportate",
|
"alert_not_supported_title": "Notifiche non supportate",
|
||||||
"notifications_attachment_file_app": "file app Android",
|
"notifications_attachment_file_app": "file app Android",
|
||||||
"notifications_no_subscriptions_description": "Fai clic sul collegamento \"{{linktext}}\" per creare o iscriverti a un argomento. Successivamente, puoi inviare messaggi tramite PUT o POST e riceverai le notifiche qui.",
|
"notifications_no_subscriptions_description": "Fai clic sul link \"{{linktext}}\" per creare o iscriverti a un topic. Successivamente, puoi inviare messaggi tramite PUT o POST e riceverai le notifiche qui.",
|
||||||
"notifications_attachment_file_audio": "file audio",
|
"notifications_attachment_file_audio": "file audio",
|
||||||
"notifications_none_for_any_description": "Per inviare notifiche a un argomento, è sufficiente PUT o POST all'URL dell'argomento. Ecco un esempio utilizzando uno dei tuoi argomenti.",
|
"notifications_none_for_any_description": "Per inviare notifiche a un topic, è sufficiente PUT o POST all'URL del topic. Ecco un esempio utilizzando uno dei tuoi topic.",
|
||||||
"notifications_click_copy_url_title": "Copia l'URL del collegamento negli appunti",
|
"notifications_click_copy_url_title": "Copia l'URL del collegamento negli appunti",
|
||||||
"prefs_notifications_sound_description_none": "Le notifiche non emettono alcun suono quando arrivano",
|
"prefs_notifications_sound_description_none": "Le notifiche non emettono alcun suono quando arrivano",
|
||||||
"publish_dialog_delay_label": "Ritardo",
|
"publish_dialog_delay_label": "Ritardo",
|
||||||
"publish_dialog_tags_placeholder": "Elenco di tag separato da virgole, ad es. avviso, backup-srv1",
|
"publish_dialog_tags_placeholder": "Elenco di tag separato da virgole, ad es. avviso, backup-srv1",
|
||||||
"publish_dialog_click_placeholder": "URL che viene aperto quando si fa clic sulla notifica",
|
"publish_dialog_click_placeholder": "URL che viene aperto quando si fa clic sulla notifica",
|
||||||
"publish_dialog_attach_placeholder": "Allega file tramite URL, ad es. https://f-droid.org/F-Droid.apk",
|
"publish_dialog_attach_placeholder": "Allega file tramite URL, ad es. https://f-droid.org/F-Droid.apk",
|
||||||
"publish_dialog_chip_topic_label": "Cambia argomento",
|
"publish_dialog_chip_topic_label": "Cambia topic",
|
||||||
"publish_dialog_details_examples_description": "Per esempi e una descrizione dettagliata di tutte le funzioni di invio, fare riferimento alla <docsLink>documentazione</docsLink>.",
|
"publish_dialog_details_examples_description": "Per esempi e una descrizione dettagliata di tutte le funzioni di invio, fare riferimento alla <docsLink>documentazione</docsLink>.",
|
||||||
"publish_dialog_attached_file_filename_placeholder": "Nome file allegato",
|
"publish_dialog_attached_file_filename_placeholder": "Nome file allegato",
|
||||||
"emoji_picker_search_placeholder": "Cerca emoji",
|
"emoji_picker_search_placeholder": "Cerca emoji",
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
"subscribe_dialog_subscribe_button_subscribe": "Iscriviti",
|
"subscribe_dialog_subscribe_button_subscribe": "Iscriviti",
|
||||||
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
|
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
|
||||||
"prefs_notifications_min_priority_title": "Priorità minima",
|
"prefs_notifications_min_priority_title": "Priorità minima",
|
||||||
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci nome utente e password per iscriverti.",
|
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
|
||||||
"common_back": "Indietro",
|
"common_back": "Indietro",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
|
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
|
||||||
"prefs_notifications_title": "Notifiche",
|
"prefs_notifications_title": "Notifiche",
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Nessun numero verificato",
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Nessun numero verificato",
|
||||||
"account_basics_phone_numbers_title": "Numeri di telefono",
|
"account_basics_phone_numbers_title": "Numeri di telefono",
|
||||||
"account_basics_phone_numbers_dialog_description": "Per usare la funzionalità di notifica tramite chiamata telefonica, devi aggiungere e verificare almeno un numero di telefono. La verifica può essere fatta tramite SMS o chiamata telefonica.",
|
"account_basics_phone_numbers_dialog_description": "Per usare la funzionalità di notifica tramite chiamata telefonica, devi aggiungere e verificare almeno un numero di telefono. La verifica può essere fatta tramite SMS o chiamata telefonica.",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} argomento riservato",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} topic riservato",
|
||||||
"account_upgrade_dialog_billing_contact_email": "Per domande di fatturazione, <Link>contattaci</Link> direttamente.",
|
"account_upgrade_dialog_billing_contact_email": "Per domande di fatturazione, <Link>contattaci</Link> direttamente.",
|
||||||
"account_upgrade_dialog_tier_current_label": "Attuale",
|
"account_upgrade_dialog_tier_current_label": "Attuale",
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Numero di telefono",
|
"account_basics_phone_numbers_dialog_number_label": "Numero di telefono",
|
||||||
@@ -276,13 +276,13 @@
|
|||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Invia SMS",
|
"account_basics_phone_numbers_dialog_verify_button_sms": "Invia SMS",
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Ancora nessun numero di telefono",
|
"account_basics_phone_numbers_no_phone_numbers_yet": "Ancora nessun numero di telefono",
|
||||||
"account_basics_phone_numbers_dialog_title": "Aggiungi un numero di telefono",
|
"account_basics_phone_numbers_dialog_title": "Aggiungi un numero di telefono",
|
||||||
"account_upgrade_dialog_button_cancel": "Annulla",
|
"account_upgrade_dialog_button_cancel": "Cancella",
|
||||||
"account_upgrade_dialog_billing_contact_website": "Per domande di fatturazione, visita per favore in nostro <Link>sito</Link>.",
|
"account_upgrade_dialog_billing_contact_website": "Per domande di fatturazione, visita per favore in nostro <Link>sito</Link>.",
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Annulla iscrizione",
|
"account_upgrade_dialog_button_cancel_subscription": "Cancella iscrizione",
|
||||||
"account_basics_phone_numbers_description": "Per notifiche via chiamata",
|
"account_basics_phone_numbers_description": "Per notifiche via chiamata",
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Numero di telefono copiato negli appunti",
|
"account_basics_phone_numbers_copied_to_clipboard": "Numero di telefono copiato negli appunti",
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "es. +391234567890",
|
"account_basics_phone_numbers_dialog_number_placeholder": "p. e. +391234567890",
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "es. 123456",
|
"account_basics_phone_numbers_dialog_code_placeholder": "p. e. 123456",
|
||||||
"account_tokens_title": "Token d'accesso",
|
"account_tokens_title": "Token d'accesso",
|
||||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} all'anno. Addebitato annualmente.",
|
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} all'anno. Addebitato annualmente.",
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Chiama",
|
"account_basics_phone_numbers_dialog_channel_call": "Chiama",
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
"account_upgrade_dialog_tier_selected_label": "Selezionato",
|
"account_upgrade_dialog_tier_selected_label": "Selezionato",
|
||||||
"account_upgrade_dialog_button_update_subscription": "Aggiorna iscrizione",
|
"account_upgrade_dialog_button_update_subscription": "Aggiorna iscrizione",
|
||||||
"account_usage_attachment_storage_title": "Archivio allegati",
|
"account_usage_attachment_storage_title": "Archivio allegati",
|
||||||
"account_delete_dialog_description": "Il tuo account sarà permanentemente eliminato insieme a tutti i tuoi dati presenti sul server. Dopo l'eliminazione, il tuo nome utente non sarà disponibile per 7 giorni. Se desideri davvero procedere, inserisci la tua password nella seguente casella.",
|
"account_delete_dialog_description": "Il tuo account sarà permanentemente cancellato assieme a tutti i tuoi dati presenti sul server. Dopo la cancellazione, la tua username non sarà disponibile per 7 giorni. Se desideri davvero procedere, inserisci la tua password nella seguente casella.",
|
||||||
"account_delete_dialog_button_cancel": "Annulla",
|
"account_delete_dialog_button_cancel": "Annulla",
|
||||||
"account_usage_calls_title": "Chiamate effettuate",
|
"account_usage_calls_title": "Chiamate effettuate",
|
||||||
"account_delete_description": "Elimina permanentemente il tuo account",
|
"account_delete_description": "Elimina permanentemente il tuo account",
|
||||||
@@ -326,7 +326,7 @@
|
|||||||
"account_tokens_dialog_title_edit": "Modifica token di accesso",
|
"account_tokens_dialog_title_edit": "Modifica token di accesso",
|
||||||
"account_tokens_dialog_button_create": "Crea token",
|
"account_tokens_dialog_button_create": "Crea token",
|
||||||
"account_tokens_dialog_button_update": "Aggiorna token",
|
"account_tokens_dialog_button_update": "Aggiorna token",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} email giornaliere",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} e-mails giornaliere",
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} messaggi giornalieri",
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} messaggi giornalieri",
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} spazio di archiviazione totale",
|
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} spazio di archiviazione totale",
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
"account_tokens_dialog_title_create": "Crea token di accesso",
|
"account_tokens_dialog_title_create": "Crea token di accesso",
|
||||||
"account_tokens_dialog_button_cancel": "Annulla",
|
"account_tokens_dialog_button_cancel": "Annulla",
|
||||||
"web_push_unknown_notification_body": "Potrebbe essere necessario aggiornare ntfy aprendo l'app web",
|
"web_push_unknown_notification_body": "Potrebbe essere necessario aggiornare ntfy aprendo l'app web",
|
||||||
"account_upgrade_dialog_proration_info": "<strong>Prorata</strong>: quando si esegue l'aggiornamento tra piani a pagamento, la differenza di prezzo verrà <strong>addebitata immediatamente</strong>. Quando si esegue il ritorna ad un livello inferiore, il saldo verrà utilizzato per pagare i periodi di fatturazione futuri.",
|
"account_upgrade_dialog_proration_info": "<strong>Prorata</strong>: quando si esegue l'upgrade tra piani a pagamento, la differenza di prezzo verrà <strong>addebitata immediatamente</strong>. Quando si esegue il downgrade a un livello inferiore, il saldo verrà utilizzato per pagare i periodi di fatturazione futuri.",
|
||||||
"account_tokens_table_last_access_header": "Ultimo accesso",
|
"account_tokens_table_last_access_header": "Ultimo accesso",
|
||||||
"account_tokens_table_expires_header": "Scade",
|
"account_tokens_table_expires_header": "Scade",
|
||||||
"account_tokens_table_never_expires": "Non scade mai",
|
"account_tokens_table_never_expires": "Non scade mai",
|
||||||
|
|||||||
@@ -187,6 +187,5 @@
|
|||||||
"prefs_users_dialog_username_label": "사용자 이름, 예를 들면 phil",
|
"prefs_users_dialog_username_label": "사용자 이름, 예를 들면 phil",
|
||||||
"prefs_users_dialog_password_label": "비밀번호",
|
"prefs_users_dialog_password_label": "비밀번호",
|
||||||
"priority_max": "최상",
|
"priority_max": "최상",
|
||||||
"error_boundary_description": "이것은 당연히 발생되어서는 안됩니다. 굉장히 죄송합니다.<br/>가능하시다면 <githubLink>이 문제를 깃허브에 제보</githubLink>해 주시거나, <discordLink>디스코드 서버</discordLink>나 <matrixLink>Matrix</matrixLink>를 통해 알려주세요.",
|
"error_boundary_description": "이것은 당연히 발생되어서는 안됩니다. 굉장히 죄송합니다.<br/>가능하시다면 <githubLink>이 문제를 깃허브에 제보</githubLink>해 주시거나, <discordLink>디스코드 서버</discordLink>나 <matrixLink>Matrix</matrixLink>를 통해 알려주세요."
|
||||||
"common_copy_to_clipboard": "클립보드에 복사"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"common_cancel": "Откажи",
|
|
||||||
"common_save": "Зачувај",
|
|
||||||
"common_add": "Додади",
|
|
||||||
"common_back": "Назад",
|
|
||||||
"common_copy_to_clipboard": "Копирај",
|
|
||||||
"action_bar_profile_logout": "Одјави се",
|
|
||||||
"action_bar_sign_in": "Најави се",
|
|
||||||
"action_bar_sign_up": "Регистрирај се",
|
|
||||||
"message_bar_type_message": "Пишете порака тука",
|
|
||||||
"action_bar_profile_title": "Профил",
|
|
||||||
"action_bar_profile_settings": "Подесувања",
|
|
||||||
"signup_form_username": "Корисничко име",
|
|
||||||
"signup_form_password": "Лозинка",
|
|
||||||
"signup_form_confirm_password": "Повтори лозинка",
|
|
||||||
"login_form_button_submit": "Најави се",
|
|
||||||
"login_link_signup": "Регистрирај се",
|
|
||||||
"signup_form_button_submit": "Регистрирај се",
|
|
||||||
"action_bar_settings": "Подесувања",
|
|
||||||
"signup_title": "Создади ntfy профил",
|
|
||||||
"signup_form_toggle_password_visibility": "Покажи/сокриј лозинка",
|
|
||||||
"signup_already_have_account": "Имате профил? Најавете се!",
|
|
||||||
"signup_disabled": "Регистрирање е исклучено",
|
|
||||||
"signup_error_username_taken": "Корисничкото име {{username}} е веќе земено",
|
|
||||||
"signup_error_creation_limit_reached": "Лимитот на создадени профили е надминат",
|
|
||||||
"login_title": "Најавете се на вашиот ntfy профил",
|
|
||||||
"login_disabled": "Најавувањето е исклучено",
|
|
||||||
"action_bar_show_menu": "Покажи мени",
|
|
||||||
"action_bar_logo_alt": "ntfy лого",
|
|
||||||
"action_bar_account": "Профил",
|
|
||||||
"action_bar_change_display_name": "Промени покажано име",
|
|
||||||
"action_bar_reservation_add": "Резервирај тема",
|
|
||||||
"action_bar_reservation_edit": "Промени резервација",
|
|
||||||
"account_basics_title": "Профил",
|
|
||||||
"account_basics_username_title": "Корисничко име",
|
|
||||||
"nav_button_account": "Профил",
|
|
||||||
"nav_button_settings": "Подесувања",
|
|
||||||
"nav_button_documentation": "Документација",
|
|
||||||
"notifications_attachment_copy_url_button": "Копирај URL",
|
|
||||||
"publish_dialog_message_label": "Порака",
|
|
||||||
"action_bar_reservation_delete": "Отстрани резервација",
|
|
||||||
"action_bar_reservation_limit_reached": "Достигната е границата",
|
|
||||||
"action_bar_send_test_notification": "Испрати тест нотификација",
|
|
||||||
"action_bar_clear_notifications": "Исчисти ги сите нотификации",
|
|
||||||
"action_bar_mute_notifications": "Загуши ги нотификациите",
|
|
||||||
"action_bar_unsubscribe": "Отпиши се",
|
|
||||||
"action_bar_toggle_action_menu": "Отвори/затвори мени за акција",
|
|
||||||
"message_bar_error_publishing": "Грешки при публикација на нотификацијата",
|
|
||||||
"message_bar_show_dialog": "Покажи дијалог за публикација",
|
|
||||||
"nav_topics_title": "Претплатени теми",
|
|
||||||
"nav_button_all_notifications": "Сите нотификации",
|
|
||||||
"nav_button_publish_message": "Објави нотификација",
|
|
||||||
"nav_button_subscribe": "Претплати се на тема"
|
|
||||||
}
|
|
||||||
@@ -309,100 +309,5 @@
|
|||||||
"account_delete_dialog_button_cancel": "Cancelar",
|
"account_delete_dialog_button_cancel": "Cancelar",
|
||||||
"account_upgrade_dialog_cancel_warning": "Isto irá <strong>cancelar a sua assinatura</strong>, e fazer downgrade da sua conta em {{date}}. Nessa data, tópicos reservados bem como mensagens guardadas no servidor <strong>serão eliminados</strong>.",
|
"account_upgrade_dialog_cancel_warning": "Isto irá <strong>cancelar a sua assinatura</strong>, e fazer downgrade da sua conta em {{date}}. Nessa data, tópicos reservados bem como mensagens guardadas no servidor <strong>serão eliminados</strong>.",
|
||||||
"account_upgrade_dialog_proration_info": "<strong>Proporção</strong>: Quando atualizar entre planos pagos, a diferença de preço será <strong>debitada imediatamente</strong>. Quando efetuar um downgrade para um escalão inferior, o saldo disponível será usado para futuros períodos de faturação.",
|
"account_upgrade_dialog_proration_info": "<strong>Proporção</strong>: Quando atualizar entre planos pagos, a diferença de preço será <strong>debitada imediatamente</strong>. Quando efetuar um downgrade para um escalão inferior, o saldo disponível será usado para futuros períodos de faturação.",
|
||||||
"prefs_users_description_no_sync": "Utilizadores e palavras-passe não estão sincronizados com a sua conta.",
|
"prefs_users_description_no_sync": "Utilizadores e palavras-passe não estão sincronizados com a sua conta."
|
||||||
"account_upgrade_dialog_reservations_warning_one": "O nível selecionado permite menos tópicos reservados do que o nível atual. Antes de alterar o seu nível, <strong>apague pelo menos uma reserva</strong>. Pode remover reservas nas <Link>Configurações</Link>.",
|
|
||||||
"account_upgrade_dialog_reservations_warning_other": "O nível selecionado permite menos tópicos reservados do que o seu nível atual. Antes de mudar o seu nível, <strong>por favor apague ao menos {{count}} reservas</strong>. Pode remover reservas nas <Link>Configurações</Link>.",
|
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tópico reservado",
|
|
||||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} tópicos reservados",
|
|
||||||
"account_upgrade_dialog_tier_features_no_reservations": "Sem tópicos reservados",
|
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensagen diária",
|
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} mensagens diárias",
|
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} email diário",
|
|
||||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} emails diários",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} chamadas diárias",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} chamadas telefônicas diárias",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Nenhuma chamada",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} por ficheiro",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} armazenamento total",
|
|
||||||
"account_upgrade_dialog_tier_price_per_month": "mês",
|
|
||||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} por ano. Cobrado mensalmente.",
|
|
||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} cobrado anualmente. Gravar {{save}}.",
|
|
||||||
"account_upgrade_dialog_tier_selected_label": "Selecionado",
|
|
||||||
"account_upgrade_dialog_tier_current_label": "Atual",
|
|
||||||
"account_upgrade_dialog_billing_contact_email": "Para questões de cobrança, <Link>entre em contacto conosco</Link> diretamente.",
|
|
||||||
"account_upgrade_dialog_billing_contact_website": "Para perguntas sobre o faturamento, consulte o nosso <Link>website</Link>.",
|
|
||||||
"account_upgrade_dialog_button_cancel": "Cancelar",
|
|
||||||
"account_upgrade_dialog_button_redirect_signup": "Cadastre-se agora",
|
|
||||||
"account_upgrade_dialog_button_pay_now": "Pague agora para assinar",
|
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Cancelar assinatura",
|
|
||||||
"account_upgrade_dialog_button_update_subscription": "Atualizar assinatura",
|
|
||||||
"account_tokens_title": "Tokens de Acesso",
|
|
||||||
"account_tokens_description": "Use tokens de acesso ao publicar e assinar por meio da API ntfy, para que não precise enviar as credenciais da sua conta. Consulte a <Link>documentação</Link> para saber mais.",
|
|
||||||
"account_tokens_table_token_header": "Token",
|
|
||||||
"account_tokens_table_label_header": "Rótulo",
|
|
||||||
"account_tokens_table_last_access_header": "Último acesso",
|
|
||||||
"account_tokens_table_expires_header": "Expira",
|
|
||||||
"account_tokens_table_never_expires": "Nunca expira",
|
|
||||||
"account_tokens_table_current_session": "Sessão atual do navegador",
|
|
||||||
"account_tokens_table_copied_to_clipboard": "Token de acesso copiado",
|
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Não é possível editar ou apagar o token da sessão atual",
|
|
||||||
"account_tokens_table_create_token_button": "Criar token de acesso",
|
|
||||||
"account_tokens_table_last_origin_tooltip": "Do endereço IP {{ip}}, clique para pesquisar",
|
|
||||||
"account_tokens_dialog_title_create": "Criar token de acesso",
|
|
||||||
"account_tokens_dialog_title_edit": "Editar token de acesso",
|
|
||||||
"account_tokens_dialog_title_delete": "Apagar token de acesso",
|
|
||||||
"account_tokens_dialog_label": "Rótulo, por exemplo, notificações de Radarr",
|
|
||||||
"account_tokens_dialog_button_create": "Criar token",
|
|
||||||
"account_tokens_dialog_button_update": "Atualizar token",
|
|
||||||
"account_tokens_dialog_button_cancel": "Cancelar",
|
|
||||||
"account_tokens_dialog_expires_label": "O token de acesso expira em",
|
|
||||||
"account_tokens_dialog_expires_unchanged": "Deixar a data de validade inalterada",
|
|
||||||
"account_tokens_dialog_expires_x_hours": "O token expira em {{hours}} horas",
|
|
||||||
"account_tokens_dialog_expires_x_days": "O token expira em {{days}} dias",
|
|
||||||
"account_tokens_dialog_expires_never": "O token nunca expira",
|
|
||||||
"account_tokens_delete_dialog_title": "Apagar token de acesso",
|
|
||||||
"account_tokens_delete_dialog_description": "Antes de apagar um token de acesso, certifique-se de que nenhuma aplicação ou script esteja usando-lo ativamente. <strong>Esta ação não pode ser desfeita</strong>.",
|
|
||||||
"account_tokens_delete_dialog_submit_button": "Apagar token permanentemente",
|
|
||||||
"prefs_notifications_web_push_title": "Notificações em segundo plano",
|
|
||||||
"prefs_notifications_web_push_enabled_description": "As notificações são recebidas mesmo quando a aplicação Web não está em execução (via Web Push)",
|
|
||||||
"prefs_notifications_web_push_disabled_description": "As notificações são recebidas quando a aplicação Web está em execução (via WebSocket)",
|
|
||||||
"prefs_notifications_web_push_enabled": "Ativado para {{server}}",
|
|
||||||
"prefs_notifications_web_push_disabled": "Desativado",
|
|
||||||
"prefs_users_table_cannot_delete_or_edit": "Não é possível apagar ou editar o utilizador conectado",
|
|
||||||
"prefs_appearance_theme_title": "Tema",
|
|
||||||
"prefs_appearance_theme_system": "Sistema (padrão)",
|
|
||||||
"prefs_appearance_theme_dark": "Modo escuro",
|
|
||||||
"prefs_appearance_theme_light": "Modo claro",
|
|
||||||
"prefs_reservations_title": "Tópicos reservados",
|
|
||||||
"prefs_reservations_description": "Pode reservar nomes de tópicos para uso pessoal aqui. A reserva de um tópico lhe dá propriedade sobre ele e permite que defina permissões de acesso para outros utilizadores sobre o tópico.",
|
|
||||||
"prefs_reservations_limit_reached": "Atingiu o seu limite de tópicos reservados.",
|
|
||||||
"prefs_reservations_add_button": "Adicionar tópico reservado",
|
|
||||||
"prefs_reservations_edit_button": "Editar o acesso ao tópico",
|
|
||||||
"prefs_reservations_delete_button": "Redefinir o acesso ao tópico",
|
|
||||||
"prefs_reservations_table": "Tabela de tópicos reservados",
|
|
||||||
"prefs_reservations_table_topic_header": "Tópico",
|
|
||||||
"prefs_reservations_table_access_header": "Acesso",
|
|
||||||
"prefs_reservations_table_everyone_deny_all": "Somente eu posso publicar e me inscrever",
|
|
||||||
"prefs_reservations_table_everyone_read_only": "Posso publicar e me inscrever, todos podem se inscrever",
|
|
||||||
"prefs_reservations_table_everyone_write_only": "Posso publicar e me inscrever, todos podem publicar",
|
|
||||||
"prefs_reservations_table_everyone_read_write": "Todos podem publicar e se inscreverem",
|
|
||||||
"prefs_reservations_table_not_subscribed": "Não inscrito",
|
|
||||||
"prefs_reservations_table_click_to_subscribe": "Clique para se inscrever",
|
|
||||||
"prefs_reservations_dialog_title_add": "Reservar tópico",
|
|
||||||
"prefs_reservations_dialog_title_edit": "Editar tópico reservado",
|
|
||||||
"prefs_reservations_dialog_title_delete": "Apagar reserva de tópico",
|
|
||||||
"prefs_reservations_dialog_description": "A reserva de um tópico lhe dá propriedade sobre ele e permite definir permissões de acesso para outros utilizadores sobre o tópico.",
|
|
||||||
"prefs_reservations_dialog_topic_label": "Tópico",
|
|
||||||
"prefs_reservations_dialog_access_label": "Acesso",
|
|
||||||
"reservation_delete_dialog_description": "A remoção de uma reserva abre mão da propriedade sobre o tópico e permite que outros o reservem. Pode manter ou apagar as mensagens e os anexos existentes.",
|
|
||||||
"reservation_delete_dialog_action_keep_title": "Manter mensagens e anexos em cache",
|
|
||||||
"reservation_delete_dialog_action_keep_description": "As mensagens e os anexos armazenados em cache no servidor ficarão visíveis publicamente para as pessoas que souberem o nome do tópico.",
|
|
||||||
"reservation_delete_dialog_action_delete_title": "Apagar mensagens e anexos armazenados em cache",
|
|
||||||
"reservation_delete_dialog_action_delete_description": "As mensagens e os anexos armazenados em cache serão apagados permanentemente. Esta ação não pode ser desfeita.",
|
|
||||||
"reservation_delete_dialog_submit_button": "Apagar reserva",
|
|
||||||
"error_boundary_button_reload_ntfy": "Recarregar ntfy",
|
|
||||||
"web_push_subscription_expiring_title": "As notificações serão pausadas",
|
|
||||||
"web_push_subscription_expiring_body": "Abra o ntfy para continuar recebendo notificações",
|
|
||||||
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
|
|
||||||
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@
|
|||||||
"publish_dialog_checkbox_markdown": "Formatar como Markdown",
|
"publish_dialog_checkbox_markdown": "Formatar como Markdown",
|
||||||
"subscribe_dialog_subscribe_use_another_background_info": "Notificações de outros servidores não serão recebidas quando o web app não estiver aberto",
|
"subscribe_dialog_subscribe_use_another_background_info": "Notificações de outros servidores não serão recebidas quando o web app não estiver aberto",
|
||||||
"account_usage_basis_ip_description": "As estatísticas e limites de uso desta conta são baseados no seu endereço IP, portanto, podem ser compartilhados com outros usuários. Os limites mostrados acima são aproximados com base nos limites de taxa existentes.",
|
"account_usage_basis_ip_description": "As estatísticas e limites de uso desta conta são baseados no seu endereço IP, portanto, podem ser compartilhados com outros usuários. Os limites mostrados acima são aproximados com base nos limites de taxa existentes.",
|
||||||
"account_usage_cannot_create_portal_session": "Não é possível abrir o portal de cobrança",
|
"account_usage_cannot_create_portal_session": "Não foi possível abrir o portal de cobrança",
|
||||||
"account_delete_description": "Deletar sua conta permanentemente",
|
"account_delete_description": "Deletar sua conta permanentemente",
|
||||||
"account_delete_dialog_button_cancel": "Cancelar",
|
"account_delete_dialog_button_cancel": "Cancelar",
|
||||||
"account_delete_dialog_button_submit": "Deletar conta permanentemente",
|
"account_delete_dialog_button_submit": "Deletar conta permanentemente",
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
"account_tokens_table_expires_header": "Expira",
|
"account_tokens_table_expires_header": "Expira",
|
||||||
"prefs_users_description_no_sync": "Usuários e senhas não estão sincronizados com a sua conta.",
|
"prefs_users_description_no_sync": "Usuários e senhas não estão sincronizados com a sua conta.",
|
||||||
"account_tokens_description": "Use tokens de acesso ao publicar e assinar por meio da API ntfy, para que você não precise enviar as credenciais da sua conta. Consulte a <Link>documentação</Link> para saber mais.",
|
"account_tokens_description": "Use tokens de acesso ao publicar e assinar por meio da API ntfy, para que você não precise enviar as credenciais da sua conta. Consulte a <Link>documentação</Link> para saber mais.",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Não é possível editar ou apagar o token da sessão atual",
|
"account_tokens_table_cannot_delete_or_edit": "Não é possível editar ou excluir o token da sessão atual",
|
||||||
"account_tokens_dialog_title_edit": "Editar token de acesso",
|
"account_tokens_dialog_title_edit": "Editar token de acesso",
|
||||||
"account_tokens_dialog_title_delete": "Excluir token de acesso",
|
"account_tokens_dialog_title_delete": "Excluir token de acesso",
|
||||||
"prefs_reservations_table_everyone_read_write": "Todos podem publicar e se inscrever",
|
"prefs_reservations_table_everyone_read_write": "Todos podem publicar e se inscrever",
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
"account_tokens_dialog_button_update": "Atualizar token",
|
"account_tokens_dialog_button_update": "Atualizar token",
|
||||||
"prefs_reservations_table": "Tabela de tópicos reservados",
|
"prefs_reservations_table": "Tabela de tópicos reservados",
|
||||||
"prefs_reservations_table_everyone_deny_all": "Somente eu posso publicar e me inscrever",
|
"prefs_reservations_table_everyone_deny_all": "Somente eu posso publicar e me inscrever",
|
||||||
"account_tokens_delete_dialog_description": "Antes de apagar um token de acesso, certifique-se de que nenhum aplicativo ou script o esteja usando ativamente. <strong>Esta ação não pode ser desfeita</strong>.",
|
"account_tokens_delete_dialog_description": "Antes de excluir um token de acesso, certifique-se de que nenhum aplicativo ou script o esteja usando ativamente. <strong>Esta ação não pode ser desfeita</strong>.",
|
||||||
"account_tokens_delete_dialog_submit_button": "Excluir token permanentemente",
|
"account_tokens_delete_dialog_submit_button": "Excluir token permanentemente",
|
||||||
"account_tokens_dialog_expires_x_hours": "O token expira em {{hours}} horas",
|
"account_tokens_dialog_expires_x_hours": "O token expira em {{hours}} horas",
|
||||||
"account_tokens_dialog_expires_x_days": "O token expira em {{days}} dias",
|
"account_tokens_dialog_expires_x_days": "O token expira em {{days}} dias",
|
||||||
@@ -383,7 +383,7 @@
|
|||||||
"prefs_notifications_web_push_enabled": "Ativado para {{server}}",
|
"prefs_notifications_web_push_enabled": "Ativado para {{server}}",
|
||||||
"prefs_notifications_web_push_disabled": "Desativado",
|
"prefs_notifications_web_push_disabled": "Desativado",
|
||||||
"prefs_appearance_theme_title": "Tema",
|
"prefs_appearance_theme_title": "Tema",
|
||||||
"prefs_users_table_cannot_delete_or_edit": "Não é possível apagar ou editar o usuário conectado",
|
"prefs_users_table_cannot_delete_or_edit": "Não é possível excluir ou editar o usuário conectado",
|
||||||
"prefs_appearance_theme_system": "Sistema (padrão)",
|
"prefs_appearance_theme_system": "Sistema (padrão)",
|
||||||
"prefs_appearance_theme_dark": "Modo escuro",
|
"prefs_appearance_theme_dark": "Modo escuro",
|
||||||
"prefs_appearance_theme_light": "Modo claro",
|
"prefs_appearance_theme_light": "Modo claro",
|
||||||
|
|||||||
@@ -242,123 +242,5 @@
|
|||||||
"account_usage_attachment_storage_title": "Stocare atașamente",
|
"account_usage_attachment_storage_title": "Stocare atașamente",
|
||||||
"account_usage_basis_ip_description": "Statistica și limitele de utilizare pentru acest cont se bazează pe adresa ta IP, așadar pot fi partajate cu alți utilizatori. Limitele afișate mai sus sunt aproximative, bazate pe limitele de viteză existente.",
|
"account_usage_basis_ip_description": "Statistica și limitele de utilizare pentru acest cont se bazează pe adresa ta IP, așadar pot fi partajate cu alți utilizatori. Limitele afișate mai sus sunt aproximative, bazate pe limitele de viteză existente.",
|
||||||
"account_usage_reservations_none": "Nu există subiecte rezervate pentru acest cont",
|
"account_usage_reservations_none": "Nu există subiecte rezervate pentru acest cont",
|
||||||
"account_basics_tier_canceled_subscription": "Abonamentul tău a fost anulat și va fi retrogradat la un cont gratuit în data de {{date}}.",
|
"account_basics_tier_canceled_subscription": "Abonamentul tău a fost anulat și va fi retrogradat la un cont gratuit în data de {{date}}."
|
||||||
"account_delete_dialog_label": "Parolă",
|
|
||||||
"account_delete_dialog_button_cancel": "Anulează",
|
|
||||||
"account_delete_dialog_button_submit": "Șterge permanent contul",
|
|
||||||
"account_delete_dialog_billing_warning": "Ștergerea contului tău anulează imediat și abonamentul de facturare. Nu vei mai avea acces la tabloul de bord pentru facturare.",
|
|
||||||
"account_upgrade_dialog_title": "Schimbă nivelul contului",
|
|
||||||
"account_upgrade_dialog_interval_monthly": "Lunar",
|
|
||||||
"account_upgrade_dialog_interval_yearly": "Anual",
|
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save": "economisești {{discount}}%",
|
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "economisești până la {{discount}}%",
|
|
||||||
"prefs_notifications_title": "Notificări",
|
|
||||||
"prefs_notifications_sound_description_none": "Notificările nu redau niciun sunet atunci când sosesc",
|
|
||||||
"prefs_notifications_sound_description_some": "Notificările redau sunetul {{sound}} atunci când sosesc",
|
|
||||||
"prefs_notifications_min_priority_description_any": "Se afișează toate notificările, indiferent de prioritate",
|
|
||||||
"prefs_notifications_min_priority_description_x_or_higher": "Afișează notificările dacă prioritatea este {{number}} ({{name}}) sau mai mare",
|
|
||||||
"prefs_notifications_min_priority_description_max": "Afișează notificări dacă prioritatea este 5 (maxim)",
|
|
||||||
"prefs_notifications_delete_after_title": "Șterge notificările",
|
|
||||||
"prefs_notifications_delete_after_never_description": "Notificările nu sunt niciodată șterse automat",
|
|
||||||
"prefs_notifications_delete_after_three_hours_description": "Notificările sunt șterse automat după trei ore",
|
|
||||||
"prefs_notifications_delete_after_one_day_description": "Notificările sunt șterse automat după o zi",
|
|
||||||
"prefs_notifications_delete_after_one_week_description": "Notificările sunt șterse automat după o săptămână",
|
|
||||||
"prefs_notifications_delete_after_one_month_description": "Notificările sunt șterse automat după o lună",
|
|
||||||
"prefs_notifications_web_push_title": "Notificări în fundal",
|
|
||||||
"prefs_notifications_web_push_enabled_description": "Notificările sunt primite chiar și atunci când aplicația web nu rulează (prin Web Push)",
|
|
||||||
"web_push_subscription_expiring_title": "Notificările vor fi suspendate",
|
|
||||||
"web_push_subscription_expiring_body": "Deschide ntfy pentru a continua să primești notificări",
|
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "subiect rezervat {{reservations}}",
|
|
||||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} subiecte rezervate",
|
|
||||||
"account_upgrade_dialog_tier_features_no_reservations": "Nu există subiecte rezervate",
|
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} mesaj zilnic",
|
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} mesaje zilnice",
|
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} e-mail zilnic",
|
|
||||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} e-mailuri zilnice",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} apeluri telefonice zilnice",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} apeluri telefonice zilnice",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Fără apeluri telefonice",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per fișier",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} stocare totală",
|
|
||||||
"account_upgrade_dialog_tier_price_per_month": "lună",
|
|
||||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} pe an. Facturat lunar.",
|
|
||||||
"account_upgrade_dialog_tier_selected_label": "Selectat",
|
|
||||||
"account_upgrade_dialog_tier_current_label": "Actual",
|
|
||||||
"account_upgrade_dialog_button_cancel": "Anulează",
|
|
||||||
"account_upgrade_dialog_button_redirect_signup": "Înscrie-te acum",
|
|
||||||
"account_upgrade_dialog_button_pay_now": "Plătește acum și abonează-te",
|
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Anulează abonamentul",
|
|
||||||
"account_tokens_table_token_header": "Token",
|
|
||||||
"account_tokens_table_label_header": "Etichetă",
|
|
||||||
"account_tokens_table_last_access_header": "Ultimul acces",
|
|
||||||
"account_tokens_table_expires_header": "Expiră",
|
|
||||||
"account_tokens_table_never_expires": "Nu expiră niciodată",
|
|
||||||
"account_tokens_table_current_session": "Sesiunea curentă a browserului",
|
|
||||||
"account_tokens_table_copied_to_clipboard": "Tokenul de acces a fost copiat",
|
|
||||||
"account_tokens_table_last_origin_tooltip": "De la adresa IP {{ip}}, faceți clic pentru a căuta",
|
|
||||||
"account_tokens_dialog_title_create": "Crează un token de acces",
|
|
||||||
"account_tokens_dialog_title_edit": "Modifică tokenul de acces",
|
|
||||||
"account_tokens_dialog_title_delete": "Șterge tokenul de acces",
|
|
||||||
"account_tokens_dialog_label": "Etichetă, de exemplu, notificări Radarr",
|
|
||||||
"account_tokens_dialog_button_create": "Crează un token",
|
|
||||||
"account_tokens_dialog_button_update": "Actualizare token",
|
|
||||||
"account_tokens_dialog_button_cancel": "Anulează",
|
|
||||||
"account_tokens_dialog_expires_label": "Tokenul de acces expiră în",
|
|
||||||
"account_tokens_dialog_expires_never": "Tokenul nu expiră niciodată",
|
|
||||||
"account_tokens_delete_dialog_title": "Șterge tokenul de acces",
|
|
||||||
"account_tokens_delete_dialog_submit_button": "Șterge definitiv tokenul",
|
|
||||||
"prefs_notifications_sound_title": "Sunet de notificare",
|
|
||||||
"prefs_notifications_sound_no_sound": "Niciun sunet",
|
|
||||||
"prefs_notifications_sound_play": "Redă sunetul selectat",
|
|
||||||
"prefs_notifications_min_priority_title": "Prioritate minimă",
|
|
||||||
"prefs_notifications_min_priority_any": "Orice prioritate",
|
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Prioritate scăzută și mai mare",
|
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Prioritate implicită și mai mare",
|
|
||||||
"prefs_notifications_min_priority_high_and_higher": "Prioritate ridicată și mai mare",
|
|
||||||
"prefs_notifications_min_priority_max_only": "Numai prioritate maximă",
|
|
||||||
"prefs_notifications_delete_after_never": "Niciodată",
|
|
||||||
"prefs_notifications_delete_after_three_hours": "După trei ore",
|
|
||||||
"prefs_notifications_delete_after_one_day": "După o zi",
|
|
||||||
"prefs_notifications_delete_after_one_week": "După o săptămână",
|
|
||||||
"prefs_notifications_delete_after_one_month": "După o lună",
|
|
||||||
"prefs_notifications_web_push_disabled_description": "Notificările sunt primite atunci când aplicația web rulează (prin WebSocket)",
|
|
||||||
"prefs_notifications_web_push_enabled": "Activat pentru {{server}}",
|
|
||||||
"prefs_notifications_web_push_disabled": "Dezactivat",
|
|
||||||
"prefs_users_title": "Gestionează utilizatorii",
|
|
||||||
"prefs_users_description_no_sync": "Utilizatorii și parolele nu sunt sincronizate cu contul tău.",
|
|
||||||
"prefs_users_table": "Tabel utilizatori",
|
|
||||||
"prefs_users_add_button": "Adăugă utilizator",
|
|
||||||
"prefs_users_edit_button": "Modifică utilizatorul",
|
|
||||||
"prefs_users_delete_button": "Șterge utilizatorul",
|
|
||||||
"prefs_users_table_cannot_delete_or_edit": "Nu se poate șterge sau modifica utilizatorul conectat",
|
|
||||||
"prefs_users_table_user_header": "Utilizator",
|
|
||||||
"prefs_users_table_base_url_header": "URL-ul serviciului",
|
|
||||||
"prefs_users_dialog_title_add": "Adaugă utilizator",
|
|
||||||
"prefs_users_dialog_title_edit": "Modifică utilizatorul",
|
|
||||||
"prefs_users_dialog_base_url_label": "URL-ul serviciului, de exemplu https://ntfy.sh",
|
|
||||||
"prefs_users_dialog_username_label": "Nume de utilizator, de ex. ionel",
|
|
||||||
"prefs_users_dialog_password_label": "Parolă",
|
|
||||||
"prefs_appearance_title": "Aspect",
|
|
||||||
"prefs_appearance_language_title": "Limbă",
|
|
||||||
"prefs_appearance_theme_title": "Temă",
|
|
||||||
"prefs_appearance_theme_system": "Sistem (implicit)",
|
|
||||||
"prefs_appearance_theme_dark": "Mod întunecat",
|
|
||||||
"prefs_appearance_theme_light": "Mod luminos",
|
|
||||||
"prefs_reservations_title": "Subiecte rezervate",
|
|
||||||
"prefs_reservations_limit_reached": "Ai atins limita de subiecte rezervate.",
|
|
||||||
"prefs_reservations_add_button": "Adaugă un subiect rezervat",
|
|
||||||
"prefs_reservations_delete_button": "Resetează accesul la topic",
|
|
||||||
"prefs_reservations_table_access_header": "Acces",
|
|
||||||
"prefs_reservations_table_everyone_deny_all": "Numai eu pot publica și mă pot abona",
|
|
||||||
"prefs_reservations_table_not_subscribed": "Neabonat",
|
|
||||||
"prefs_reservations_dialog_access_label": "Acces",
|
|
||||||
"reservation_delete_dialog_action_keep_title": "Păstrează mesajele și atașamentele în cache",
|
|
||||||
"prefs_users_description": "Adaugă/elimină utilizatori pentru subiectele protejate aici. Reține că numele de utilizator și parola sunt stocate în memoria locală a browserului.",
|
|
||||||
"reservation_delete_dialog_submit_button": "Șterge rezervarea",
|
|
||||||
"priority_min": "minim",
|
|
||||||
"priority_low": "scăzut",
|
|
||||||
"priority_default": "implicit",
|
|
||||||
"priority_high": "ridicat",
|
|
||||||
"priority_max": "maxim",
|
|
||||||
"error_boundary_button_reload_ntfy": "Reîncarcă ntfy"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து <docsLink>ஆவணங்கள் </docsLink> ஐப் பார்க்கவும்.",
|
"publish_dialog_details_examples_description": "எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து <ock இணைப்பு> ஆவணங்கள் </டாக்ச் இணைப்பு> ஐப் பார்க்கவும்.",
|
||||||
"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}} இல் தரமிறக்குகிறது. அந்தத் தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாகச் சேமிக்கப்பட்ட செய்திகளும் <strong>நீக்கப்படும் </strong>.",
|
"account_upgrade_dialog_cancel_warning": "இது <strong> உங்கள் சந்தாவை ரத்துசெய்யும் </strong>, மேலும் உங்கள் கணக்கை {{date} at இல் தரமிறக்குகிறது. அந்த தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாக சேமிக்கப்பட்ட செய்திகளும் நீக்கப்படும் </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>. <Link>அமைப்புகள்</Link> இல் முன்பதிவுகளை அகற்றலாம்.",
|
"account_upgrade_dialog_reservations_warning_one": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கை விட குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தது ஒரு முன்பதிவை நீக்கு </strong>. <இணைப்பு> அமைப்புகள் </இணைப்பு> இல் முன்பதிவுகளை அகற்றலாம்.",
|
||||||
"account_upgrade_dialog_reservations_warning_other": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு </strong> ஐ நீக்கவும். <Link>அமைப்புகள்</Link> இல் முன்பதிவுகளை அகற்றலாம்.",
|
"account_upgrade_dialog_reservations_warning_other": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கை விட குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு </strong> ஐ நீக்கவும். <இணைப்பு> அமைப்புகள் </இணைப்பு> இல் முன்பதிவுகளை அகற்றலாம்.",
|
||||||
"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": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து <Link>எங்களைத் தொடர்பு கொள்ளவும் </Link>நேரடியாக.",
|
"account_upgrade_dialog_billing_contact_email": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து <இணைப்பு> எங்களை தொடர்பு கொள்ளவும் </இணைப்பு> நேரடியாக.",
|
||||||
"account_upgrade_dialog_button_cancel": "ரத்துசெய்",
|
"account_upgrade_dialog_button_cancel": "ரத்துசெய்",
|
||||||
"account_upgrade_dialog_billing_contact_website": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் <Link>வலைத்தளம்</Link> ஐப் பார்க்கவும்.",
|
"account_upgrade_dialog_billing_contact_website": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் <இணைப்பு> வலைத்தளம் </இணைப்பு> ஐப் பார்க்கவும்.",
|
||||||
"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 பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும்போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய <Link> ஆவணங்கள்</Link> ஐப் பாருங்கள்.",
|
"account_tokens_description": "NTFY பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும் போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய <இணைப்பு> ஆவணங்கள் </இணைப்பு> ஐப் பாருங்கள்.",
|
||||||
"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": "இது நிச்சயமாக நடக்கக் கூடாது. இதுகுறித்து மிகவும் வருந்துகிறேன்.<br/>உங்களிடம் ஒரு நிமிடம் இருந்தால், தயவுசெய்து <githubLink>இதை GitHub இல் புகாரளிக்கவும்</githubLink>, அல்லது <discordLink>Discord</discordLink> அல்லது <matrixLink>Matrix</matrixLink> வழியாக எங்களுக்குத் தெரியப்படுத்தவும்.",
|
"error_boundary_description": "இது வெளிப்படையாக நடக்கக்கூடாது. இதைப் பற்றி மிகவும் வருந்துகிறேன். .",
|
||||||
"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": "மேலும் தகவலுக்கு, <websiteLink>வலைத்தளம் </websiteLink> அல்லது <docsLink> ஆவணங்கள் </docsLink> ஐப் பாருங்கள்.",
|
"notifications_more_details": "மேலும் தகவலுக்கு, </webititeLink> வலைத்தளம் </websiteLink> அல்லது <ockslink> ஆவணங்கள் </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 தேவை, மேலும் உங்கள் உலாவித் தனிப்பட்ட உலாவல் பயன்முறையில் IndexedDB ஐ ஆதரிக்காது.<br/><br/>இது துரதிர்ஷ்டவசமானது என்றாலும், ntfy வலை பயன்பாட்டைத் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்படுத்துவது உண்மையில் அர்த்தமற்றது, ஏனெனில் அனைத்தும் உலாவிச் சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இதைப் பற்றி நீங்கள் <githubLink>இந்த GitHub சிக்கலில் மேலும் படிக்கலாம்</githubLink>, அல்லது <discordLink>Discord</discordLink> அல்லது <matrixLink>Matrix</matrixLink> இல் எங்களுடன் பேசலாம்.",
|
"error_boundary_unsupported_indexeddb_description": "NTFY வலை பயன்பாட்டிற்கு செயல்பட குறியீட்டு தேவை, மற்றும் உங்கள் உலாவி தனிப்பட்ட உலாவல் பயன்முறையில் IndexEDDB ஐ ஆதரிக்காது. எப்படியிருந்தாலும் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்பாடு, ஏனென்றால் அனைத்தும் உலாவி சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இந்த அறிவிலிமையம் இதழில் </githublink> இல் <githublink> பற்றி நீங்கள் மேலும் படிக்கலாம் அல்லது <scordlink> டிச்கார்ட் </disordlink> அல்லது <agadgaglelink> மேட்ரிக்ச் </மேட்ரிக்ச்லிங்க்> இல் எங்களுடன் பேசலாம்.",
|
||||||
"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": "சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது",
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"common_cancel": "ยกเลิก",
|
|
||||||
"common_save": "บันทึก",
|
|
||||||
"common_add": "เพิ่ม",
|
|
||||||
"common_back": "กลับ",
|
|
||||||
"common_copy_to_clipboard": "คัดลอกไปยังคลิปบอร์ด",
|
|
||||||
"signup_title": "สร้างบัญชี ntfy",
|
|
||||||
"signup_form_username": "ชื่อผู้ใช้",
|
|
||||||
"signup_form_password": "รหัสผ่าน",
|
|
||||||
"signup_form_confirm_password": "ยืนยันรหัสผ่าน",
|
|
||||||
"signup_form_button_submit": "สมัครสมาชิก",
|
|
||||||
"signup_form_toggle_password_visibility": "สลับการมองเห็นรหัสผ่าน",
|
|
||||||
"signup_already_have_account": "มีบัญชีอยู่แล้วใช่ไหม? เข้าสู่ระบบ!",
|
|
||||||
"signup_disabled": "การลงทะเบียนถูกปิดใช้งาน",
|
|
||||||
"signup_error_username_taken": "ชื่อผู้ใช้ {{username}} ถูกใช้ไปแล้ว",
|
|
||||||
"signup_error_creation_limit_reached": "ถึงขีดจำกัดการสร้างบัญชีแล้ว",
|
|
||||||
"login_title": "ลงชื่อเข้าใช้บัญชี ntfy ของคุณ",
|
|
||||||
"login_form_button_submit": "ลงชื่อเข้าใช้",
|
|
||||||
"login_link_signup": "สมัครสมาชิก",
|
|
||||||
"login_disabled": "การเข้าสู่ระบบถูกปิดใช้งาน",
|
|
||||||
"action_bar_show_menu": "แสดงเมนู",
|
|
||||||
"action_bar_logo_alt": "โลโก้ ntfy",
|
|
||||||
"action_bar_settings": "การตั้งค่า",
|
|
||||||
"action_bar_account": "บัญชี",
|
|
||||||
"action_bar_change_display_name": "เปลี่ยนชื่อที่แสดง",
|
|
||||||
"action_bar_reservation_add": "หัวข้อที่สงวนไว้",
|
|
||||||
"action_bar_reservation_edit": "เปลี่ยนแปลงการจอง",
|
|
||||||
"action_bar_reservation_delete": "ลบการจอง",
|
|
||||||
"action_bar_reservation_limit_reached": "ถึงขีดจำกัดแล้ว",
|
|
||||||
"action_bar_send_test_notification": "ทดสอบการส่งการแจ้งเตือน",
|
|
||||||
"action_bar_clear_notifications": "ล้างการแจ้งเตือนทั้งหมด",
|
|
||||||
"action_bar_mute_notifications": "ปิดเสียงการแจ้งเตือนชั่วคราว",
|
|
||||||
"action_bar_unmute_notifications": "เปิดเสียงการแจ้งเตือน",
|
|
||||||
"action_bar_unsubscribe": "ยกเลิกการสมัครรับ",
|
|
||||||
"action_bar_toggle_mute": "ปิดเสียง/เปิดเสียงการแจ้งเตือน",
|
|
||||||
"action_bar_toggle_action_menu": "เปิด/ปิดเมนูการดำเนินการ",
|
|
||||||
"action_bar_profile_title": "โปรไฟล์",
|
|
||||||
"action_bar_profile_settings": "การตั้งค่า",
|
|
||||||
"action_bar_profile_logout": "ออกจากระบบ",
|
|
||||||
"action_bar_sign_in": "ลงชื่อเข้าใช้",
|
|
||||||
"action_bar_sign_up": "สมัครสมาชิก",
|
|
||||||
"message_bar_type_message": "พิมพ์ข้อความที่นี่",
|
|
||||||
"message_bar_publish": "เผยแพร่ข้อความ",
|
|
||||||
"nav_topics_title": "หัวข้อที่สมัครรับข้อมูล",
|
|
||||||
"nav_button_all_notifications": "การแจ้งเตือนทั้งหมด",
|
|
||||||
"nav_button_account": "บัญชี",
|
|
||||||
"nav_button_settings": "การตั้งค่า",
|
|
||||||
"nav_button_documentation": "เอกสารประกอบ",
|
|
||||||
"nav_button_publish_message": "เผยแพร่การแจ้งเตือน",
|
|
||||||
"message_bar_error_publishing": "เกิดข้อผิดพลาดในการเผยแพร่การแจ้งเตือน",
|
|
||||||
"message_bar_show_dialog": "แสดงกล่องโต้ตอบการเผยแพร่",
|
|
||||||
"nav_button_subscribe": "สมัครรับหัวข้อ",
|
|
||||||
"nav_button_muted": "ปิดการแจ้งเตือน",
|
|
||||||
"nav_button_connecting": "การเชื่อมต่อ",
|
|
||||||
"nav_upgrade_banner_label": "อัพเกรดเป็น ntfy Pro"
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"prefs_notifications_delete_after_never": "Hiçbir zaman",
|
"prefs_notifications_delete_after_never": "Hiçbir zaman",
|
||||||
"notifications_attachment_copy_url_button": "URL'yi kopyala",
|
"notifications_attachment_copy_url_button": "URL'yi kopyala",
|
||||||
"notifications_attachment_open_button": "Eki aç",
|
"notifications_attachment_open_button": "Eki aç",
|
||||||
"nav_button_documentation": "Dokümantasyon",
|
"nav_button_documentation": "Belgelendirme",
|
||||||
"nav_button_publish_message": "Bildirim yayınla",
|
"nav_button_publish_message": "Bildirim yayınla",
|
||||||
"alert_notification_permission_required_title": "Bildirimler devre dışı",
|
"alert_notification_permission_required_title": "Bildirimler devre dışı",
|
||||||
"alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin",
|
"alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin",
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
"notifications_click_open_button": "Bağlantıyı aç",
|
"notifications_click_open_button": "Bağlantıyı aç",
|
||||||
"notifications_no_subscriptions_description": "Bir konu oluşturmak veya bir konuya abone olmak için \"{{linktext}}\" bağlantısına tıklayın. Bundan sonra PUT veya POST yoluyla mesaj gönderebilirsiniz ve buradan bildirimler alırsınız.",
|
"notifications_no_subscriptions_description": "Bir konu oluşturmak veya bir konuya abone olmak için \"{{linktext}}\" bağlantısına tıklayın. Bundan sonra PUT veya POST yoluyla mesaj gönderebilirsiniz ve buradan bildirimler alırsınız.",
|
||||||
"notifications_example": "Örnek",
|
"notifications_example": "Örnek",
|
||||||
"notifications_more_details": "Daha fazla bilgi için <websiteLink>web sitesini</websiteLink> veya <docsLink>dokümantasyonu</docsLink> inceleyin.",
|
"notifications_more_details": "Daha fazla bilgi için <websiteLink>web sitesine</websiteLink> veya <docsLink>belgelendirmeye</docsLink> bakın.",
|
||||||
"publish_dialog_chip_attach_url_label": "URL ile dosya ekle",
|
"publish_dialog_chip_attach_url_label": "URL ile dosya ekle",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Varsayılan öncelik ve üstü",
|
"prefs_notifications_min_priority_default_and_higher": "Varsayılan öncelik ve üstü",
|
||||||
"prefs_notifications_delete_after_three_hours": "Üç saat sonra",
|
"prefs_notifications_delete_after_three_hours": "Üç saat sonra",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"publish_dialog_button_cancel_sending": "Göndermeyi iptal et",
|
"publish_dialog_button_cancel_sending": "Göndermeyi iptal et",
|
||||||
"prefs_notifications_delete_after_one_week": "Bir hafta sonra",
|
"prefs_notifications_delete_after_one_week": "Bir hafta sonra",
|
||||||
"prefs_notifications_delete_after_one_month": "Bir ay sonra",
|
"prefs_notifications_delete_after_one_month": "Bir ay sonra",
|
||||||
"publish_dialog_details_examples_description": "Tüm gönderme özelliklerinin örnekleri ve ayrıntılı açıklamaları için lütfen <docsLink>dokümantasyona</docsLink> bakın.",
|
"publish_dialog_details_examples_description": "Örnekler ve tüm gönderme özelliklerinin ayrıntılı açıklaması için lütfen <docsLink>belgelendirmeye</docsLink> bakın.",
|
||||||
"emoji_picker_search_placeholder": "Emoji ara",
|
"emoji_picker_search_placeholder": "Emoji ara",
|
||||||
"prefs_notifications_delete_after_title": "Bildirimleri sil",
|
"prefs_notifications_delete_after_title": "Bildirimleri sil",
|
||||||
"prefs_notifications_delete_after_one_day": "Bir gün sonra",
|
"prefs_notifications_delete_after_one_day": "Bir gün sonra",
|
||||||
|
|||||||
@@ -79,23 +79,5 @@
|
|||||||
"notifications_attachment_file_app": "tập tin Android",
|
"notifications_attachment_file_app": "tập tin Android",
|
||||||
"notifications_attachment_link_expires": "liên kết đã hết hạn {{date}}",
|
"notifications_attachment_link_expires": "liên kết đã hết hạn {{date}}",
|
||||||
"alert_not_supported_context_description": "Thông báo chỉ được hỗ trợ qua giao thức HTTPS. Đây là hạn chế của <mdnLink>API thông báo</mdnLink>.",
|
"alert_not_supported_context_description": "Thông báo chỉ được hỗ trợ qua giao thức HTTPS. Đây là hạn chế của <mdnLink>API thông báo</mdnLink>.",
|
||||||
"notifications_attachment_open_button": "Mở đính kèm",
|
"notifications_attachment_open_button": "Mở đính kèm"
|
||||||
"message_bar_error_publishing": "Lỗi khi gửi thông báo",
|
|
||||||
"message_bar_show_dialog": "Hiện hộp thoại gửi thông báo",
|
|
||||||
"message_bar_publish": "Gửi thông báo",
|
|
||||||
"nav_topics_title": "Các topic đã đăng ký",
|
|
||||||
"nav_button_publish_message": "Gửi thông báo",
|
|
||||||
"nav_button_subscribe": "Đăng ký topic",
|
|
||||||
"nav_button_muted": "Đã tắt thông báo",
|
|
||||||
"nav_upgrade_banner_description": "Đặt trước topic, nhiều thông báo & email hơn, và tệp đính kèm dung lượng lớn hơn",
|
|
||||||
"action_bar_reservation_add": "Đặt trước topic",
|
|
||||||
"action_bar_reservation_edit": "Thay đổi thông tin đặt trước",
|
|
||||||
"action_bar_reservation_delete": "Huỷ đặt trước",
|
|
||||||
"notifications_none_for_topic_title": "Bạn chưa nhận được thông báo nào cho topic này.",
|
|
||||||
"notifications_none_for_topic_description": "Để gửi thông báo đến topic này, chỉ cần dùng PUT hoặc POST đến URL của topic.",
|
|
||||||
"notifications_none_for_any_title": "Bạn chưa nhận được thông báo nào.",
|
|
||||||
"notifications_none_for_any_description": "Để gửi thông báo đến một topic, bạn chỉ cần dùng PUT hoặc POST đến URL của topic. Dưới đây là một ví dụ với một trong các topic của bạn.",
|
|
||||||
"notifications_no_subscriptions_title": "Có vẻ như bạn chưa đăng ký topic nào.",
|
|
||||||
"notifications_no_subscriptions_description": "Bấm vào liên kết \"{{linktext}}\" để tạo hoặc đăng ký một chủ đề. Sau đó, bạn có thể gửi tin nhắn qua PUT hoặc POST và sẽ nhận thông báo tại đây.",
|
|
||||||
"notifications_example": "Ví dụ"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
"account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,<strong>請至少刪除 1 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。",
|
"account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,<strong>請至少刪除 1 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。",
|
||||||
"account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,<strong>請至少刪除 {{count}} 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。",
|
"account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,<strong>請至少刪除 {{count}} 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。",
|
||||||
"account_upgrade_dialog_tier_current_label": "當前",
|
"account_upgrade_dialog_tier_current_label": "當前",
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "每個文件 {{filesize}}",
|
"account_upgrade_dialog_tier_features_attachment_file_size": "每個文件 {{filesize}} ",
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總存儲空間",
|
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總存儲空間",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "每日一通電話",
|
"account_upgrade_dialog_tier_features_calls_one": "每日一通電話",
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "每日{{calls}} 通電話",
|
"account_upgrade_dialog_tier_features_calls_other": "每日{{calls}} 通電話",
|
||||||
@@ -145,13 +145,13 @@
|
|||||||
"action_bar_unsubscribe": "取消訂閱",
|
"action_bar_unsubscribe": "取消訂閱",
|
||||||
"alert_notification_ios_install_required_description": "要接收通知,請在 iOS 上點擊共享,然後添加到主屏幕",
|
"alert_notification_ios_install_required_description": "要接收通知,請在 iOS 上點擊共享,然後添加到主屏幕",
|
||||||
"alert_notification_ios_install_required_title": "需要安裝 iOS 應用程式",
|
"alert_notification_ios_install_required_title": "需要安裝 iOS 應用程式",
|
||||||
"alert_notification_permission_denied_description": "你已禁用通知。要重新啟用通知,請在瀏覽器設置中啟用通知",
|
"alert_notification_permission_denied_description": "你已禁用通知。要重新啟用通知,請在瀏覽器設置中啟用通知。",
|
||||||
"alert_notification_permission_denied_title": "已禁用通知",
|
"alert_notification_permission_denied_title": "已禁用通知",
|
||||||
"alert_notification_permission_required_button": "現在授予",
|
"alert_notification_permission_required_button": "現在授予",
|
||||||
"alert_notification_permission_required_description": "授予瀏覽器顯示桌面通知的權限",
|
"alert_notification_permission_required_description": "授予瀏覽器顯示桌面通知的權限。",
|
||||||
"alert_notification_permission_required_title": "已禁用通知",
|
"alert_notification_permission_required_title": "已禁用通知",
|
||||||
"alert_not_supported_context_description": "通知僅支援 HTTPS。這是 <mdnLink>Notifications API</mdnLink> 的限制。",
|
"alert_not_supported_context_description": "通知僅支援 HTTPS。這是 <mdnLink>Notifications API</mdnLink> 的限制。",
|
||||||
"alert_not_supported_description": "你的瀏覽器不支援通知",
|
"alert_not_supported_description": "你的瀏覽器不支援通知。",
|
||||||
"alert_not_supported_title": "不支援通知",
|
"alert_not_supported_title": "不支援通知",
|
||||||
"common_add": "新增",
|
"common_add": "新增",
|
||||||
"common_back": "返回",
|
"common_back": "返回",
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
"notifications_none_for_topic_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題連結即可。",
|
"notifications_none_for_topic_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題連結即可。",
|
||||||
"notifications_none_for_topic_title": "你尚未收到有關此主題的任何通知。",
|
"notifications_none_for_topic_title": "你尚未收到有關此主題的任何通知。",
|
||||||
"notifications_no_subscriptions_description": "點擊 \"{{linktext}}\" 連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 發送訊息,你將在這裡收到通知。",
|
"notifications_no_subscriptions_description": "點擊 \"{{linktext}}\" 連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 發送訊息,你將在這裡收到通知。",
|
||||||
"notifications_no_subscriptions_title": "看起來你還未有任何訂閱。",
|
"notifications_no_subscriptions_title": "看起來你還未有任何訂閱",
|
||||||
"notifications_priority_x": "優先級 {{priority}}",
|
"notifications_priority_x": "優先級 {{priority}}",
|
||||||
"notifications_tags": "標記",
|
"notifications_tags": "標記",
|
||||||
"prefs_appearance_language_title": "語言",
|
"prefs_appearance_language_title": "語言",
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
"prefs_notifications_web_push_disabled_description": "當網頁程式在運行時將會收到通知 (透過 WebSocket)",
|
"prefs_notifications_web_push_disabled_description": "當網頁程式在運行時將會收到通知 (透過 WebSocket)",
|
||||||
"prefs_notifications_web_push_disabled": "己暫用",
|
"prefs_notifications_web_push_disabled": "己暫用",
|
||||||
"prefs_notifications_web_push_enabled_description": "即使網頁程式未有運街亦會收到通知 (via Web Push)",
|
"prefs_notifications_web_push_enabled_description": "即使網頁程式未有運街亦會收到通知 (via Web Push)",
|
||||||
"prefs_notifications_web_push_enabled": "己為 {{server}} 啟用",
|
"prefs_notifications_web_push_enabled": "己為 {{server}} 啟用",
|
||||||
"prefs_notifications_web_push_title": "背景通知",
|
"prefs_notifications_web_push_title": "背景通知",
|
||||||
"prefs_reservations_add_button": "新增保留主題",
|
"prefs_reservations_add_button": "新增保留主題",
|
||||||
"prefs_reservations_delete_button": "重置主題訪問",
|
"prefs_reservations_delete_button": "重置主題訪問",
|
||||||
|
|||||||
@@ -77,10 +77,7 @@ export const maybeWithBearerAuth = (headers, token) => {
|
|||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const withBasicAuth = (headers, username, password) => ({
|
export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) });
|
||||||
...headers,
|
|
||||||
Authorization: basicAuth(username, password),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const maybeWithAuth = (headers, user) => {
|
export const maybeWithAuth = (headers, user) => {
|
||||||
if (user?.password) {
|
if (user?.password) {
|
||||||
@@ -273,21 +270,3 @@ export const urlB64ToUint8Array = (base64String) => {
|
|||||||
}
|
}
|
||||||
return outputArray;
|
return outputArray;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyToClipboard = (text) => {
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
return navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
// Fallback to the older method if clipboard API is not supported (or on HTTP)
|
|
||||||
const textarea = document.createElement("textarea");
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute("readonly", ""); // Avoid mobile keyboards from popping up
|
|
||||||
textarea.style.position = "fixed"; // Avoid scroll jump
|
|
||||||
textarea.style.left = "-9999px";
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.focus();
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
return Promise.resolve();
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import CloseIcon from "@mui/icons-material/Close";
|
|||||||
import { ContentCopy, Public } from "@mui/icons-material";
|
import { ContentCopy, Public } from "@mui/icons-material";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
|
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
|
||||||
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
||||||
import { Pref, PrefGroup } from "./Pref";
|
import { Pref, PrefGroup } from "./Pref";
|
||||||
import db from "../app/db";
|
import db from "../app/db";
|
||||||
@@ -100,13 +100,15 @@ 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>
|
||||||
@@ -117,7 +119,6 @@ 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 = () => {
|
||||||
@@ -135,19 +136,9 @@ const ChangePassword = () => {
|
|||||||
<Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
|
<Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
|
||||||
⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤
|
⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤
|
||||||
</Typography>
|
</Typography>
|
||||||
{!account?.provisioned ? (
|
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
|
||||||
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
|
<EditIcon />
|
||||||
<EditIcon />
|
</IconButton>
|
||||||
</IconButton>
|
|
||||||
) : (
|
|
||||||
<Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
|
|
||||||
<span>
|
|
||||||
<IconButton disabled>
|
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
<ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||||
</Pref>
|
</Pref>
|
||||||
@@ -370,7 +361,7 @@ const PhoneNumbers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (phoneNumber) => {
|
const handleCopy = (phoneNumber) => {
|
||||||
copyToClipboard(phoneNumber);
|
navigator.clipboard.writeText(phoneNumber);
|
||||||
setSnackOpen(true);
|
setSnackOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -841,7 +832,7 @@ const TokensTable = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = async (token) => {
|
const handleCopy = async (token) => {
|
||||||
copyToClipboard(token);
|
await navigator.clipboard.writeText(token);
|
||||||
setSnackOpen(true);
|
setSnackOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -897,7 +888,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.provisioned && (
|
{token.token !== session.token() && (
|
||||||
<>
|
<>
|
||||||
<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 />
|
||||||
@@ -919,18 +910,6 @@ 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>
|
||||||
))}
|
))}
|
||||||
@@ -1069,7 +1048,6 @@ 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);
|
||||||
@@ -1083,19 +1061,9 @@ const DeleteAccount = () => {
|
|||||||
return (
|
return (
|
||||||
<Pref title={t("account_delete_title")} description={t("account_delete_description")}>
|
<Pref title={t("account_delete_title")} description={t("account_delete_description")}>
|
||||||
<div>
|
<div>
|
||||||
{!account?.provisioned ? (
|
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
|
||||||
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
|
{t("account_delete_title")}
|
||||||
{t("account_delete_title")}
|
</Button>
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
|
|
||||||
<span>
|
|
||||||
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} disabled>
|
|
||||||
{t("account_delete_title")}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
<DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||||
</Pref>
|
</Pref>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import Account from "./Account";
|
|||||||
import initI18n from "../app/i18n"; // Translations!
|
import initI18n from "../app/i18n"; // Translations!
|
||||||
import prefs, { THEME } from "../app/Prefs";
|
import prefs, { THEME } from "../app/Prefs";
|
||||||
import RTLCacheProvider from "./RTLCacheProvider";
|
import RTLCacheProvider from "./RTLCacheProvider";
|
||||||
import session from "../app/Session";
|
|
||||||
|
|
||||||
initI18n();
|
initI18n();
|
||||||
|
|
||||||
@@ -46,6 +45,7 @@ const darkModeEnabled = (prefersDarkMode, themePreference) => {
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const languageDir = i18n.dir();
|
const languageDir = i18n.dir();
|
||||||
|
|
||||||
const [account, setAccount] = useState(null);
|
const [account, setAccount] = useState(null);
|
||||||
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
|
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
|
||||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||||
@@ -60,12 +60,6 @@ const App = () => {
|
|||||||
document.dir = languageDir;
|
document.dir = languageDir;
|
||||||
}, [i18n.language, languageDir]);
|
}, [i18n.language, languageDir]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session.exists() && config.require_login && window.location.pathname !== routes.login) {
|
|
||||||
window.location.href = routes.login;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loader />}>
|
<Suspense fallback={<Loader />}>
|
||||||
<RTLCacheProvider>
|
<RTLCacheProvider>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
|||||||
import StackTrace from "stacktrace-js";
|
import StackTrace from "stacktrace-js";
|
||||||
import { CircularProgress, Link, Button } from "@mui/material";
|
import { CircularProgress, Link, Button } from "@mui/material";
|
||||||
import { Trans, withTranslation } from "react-i18next";
|
import { Trans, withTranslation } from "react-i18next";
|
||||||
import { copyToClipboard } from "../app/utils";
|
|
||||||
|
|
||||||
class ErrorBoundaryImpl extends React.Component {
|
class ErrorBoundaryImpl extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -65,7 +64,7 @@ class ErrorBoundaryImpl extends React.Component {
|
|||||||
stack += `${this.state.niceStack}\n\n`;
|
stack += `${this.state.niceStack}\n\n`;
|
||||||
}
|
}
|
||||||
stack += `${this.state.originalStack}\n`;
|
stack += `${this.state.originalStack}\n`;
|
||||||
copyToClipboard(stack);
|
navigator.clipboard.writeText(stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderUnsupportedIndexedDB() {
|
renderUnsupportedIndexedDB() {
|
||||||
|
|||||||
@@ -26,16 +26,7 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import { useOutletContext } from "react-router-dom";
|
import { useOutletContext } from "react-router-dom";
|
||||||
import { useRemark } from "react-remark";
|
import { useRemark } from "react-remark";
|
||||||
import styled from "@emotion/styled";
|
import styled from "@emotion/styled";
|
||||||
import {
|
import { formatBytes, formatShortDateTime, maybeActionErrors, openUrl, shortUrl, topicShortUrl, unmatchedTags } from "../app/utils";
|
||||||
copyToClipboard,
|
|
||||||
formatBytes,
|
|
||||||
formatShortDateTime,
|
|
||||||
maybeActionErrors,
|
|
||||||
openUrl,
|
|
||||||
shortUrl,
|
|
||||||
topicShortUrl,
|
|
||||||
unmatchedTags,
|
|
||||||
} from "../app/utils";
|
|
||||||
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
|
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
|
||||||
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
@@ -248,7 +239,7 @@ const NotificationItem = (props) => {
|
|||||||
await subscriptionManager.markNotificationRead(notification.id);
|
await subscriptionManager.markNotificationRead(notification.id);
|
||||||
};
|
};
|
||||||
const handleCopy = (s) => {
|
const handleCopy = (s) => {
|
||||||
copyToClipboard(s);
|
navigator.clipboard.writeText(s);
|
||||||
props.onShowSnack();
|
props.onShowSnack();
|
||||||
};
|
};
|
||||||
const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000;
|
const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000;
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ const baseThemeOptions = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MuiCardActions: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
overflowX: "auto",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user