Compare commits

..

23 Commits

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

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2025-08-12 18:02:13 +02:00
binwiederhier
5244e0be14 Fix tests 2025-08-09 10:04:57 -04:00
binwiederhier
6eb25f68ac Update password hash docs, add more validation on password hash 2025-08-09 07:34:19 -04:00
Philipp C. Heckel
efe7c3fa70 Merge pull request #1399 from orblivion/patch-1
Add Ntfy for Sandstorm to integrations.md
2025-08-08 22:21:43 +02:00
Philipp C. Heckel
ce4b2ae9a0 Merge pull request #1421 from binwiederhier/message-cache-lock
Message cache lock
2025-08-08 22:19:24 +02:00
Daniel Krol
4eb7dc563c Add Ntfy for Sandstorm to integrations.md 2025-07-22 18:50:18 -04:00
Thea Tischbein
03aeb707f2 feat: Add optional web app flag which requires a login for every action 2025-05-05 11:39:53 +02:00
22 changed files with 200 additions and 41 deletions

View File

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

View File

@@ -63,6 +63,7 @@ var flagsServe = append(
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-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: "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-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"}),
@@ -171,6 +172,7 @@ func execServe(c *cli.Context) error {
webRoot := c.String("web-root")
enableSignup := c.Bool("enable-signup")
enableLogin := c.Bool("enable-login")
requireLogin := c.Bool("require-login")
enableReservations := c.Bool("enable-reservations")
upstreamBaseURL := c.String("upstream-base-url")
upstreamAccessToken := c.String("upstream-access-token")
@@ -318,10 +320,12 @@ func execServe(c *cli.Context) error {
return errors.New("if upstream-base-url is set, base-url must also be set")
} 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")
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") {
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
} else if authFile == "" && (enableSignup || enableLogin || requireLogin || 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")
} else if enableSignup && !enableLogin {
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 == "") {
@@ -475,6 +479,7 @@ func execServe(c *cli.Context) error {
conf.BillingContact = billingContact
conf.EnableSignup = enableSignup
conf.EnableLogin = enableLogin
conf.RequireLogin = requireLogin
conf.EnableReservations = enableReservations
conf.EnableMetrics = enableMetrics
conf.MetricsListenHTTP = metricsListenHTTP
@@ -555,8 +560,8 @@ func parseUsers(usersRaw []string) ([]*user.User, error) {
role := user.Role(strings.TrimSpace(parts[2]))
if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine)
} else if err := user.ValidPasswordHash(passwordHash); err != nil {
return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error())
} else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
} else if !user.AllowedRole(role) {
return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
}

View File

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

View File

@@ -88,7 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`):
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
NTFY_AUTH_DEFAULT_ACCESS: deny-all
NTFY_AUTH_USERS: 'phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin'
NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
NTFY_BEHIND_PROXY: true
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
NTFY_ENABLE_LOGIN: true
@@ -1698,6 +1698,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-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) |
| `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-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 |

View File

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

View File

@@ -3679,13 +3679,13 @@ authParam = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM0
The following command will generate the appropriate value for you on *nix systems:
```
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
echo -n "Basic `echo -n 'testuser:fakepassword' | base64 -w0`" | base64 -w0 | tr -d '='
```
For access tokens, you can use this instead:
```
echo -n "Bearer faketoken" | base64 | tr -d '='
echo -n "Bearer faketoken" | base64 -w0 | tr -d '='
```
## Advanced features

View File

@@ -1470,11 +1470,16 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
### ntfy server v2.15.0 (UNRELEASED)
**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:**
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
### ntfy Android app v1.16.1 (UNRELEASED)

View File

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

View File

@@ -9,8 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/payments"
"io"
"net"
"net/http"
@@ -33,7 +31,9 @@ import (
"github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig"
@@ -600,6 +600,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin,
RequireLogin: s.config.RequireLogin,
EnableSignup: s.config.EnableSignup,
EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "",

View File

@@ -258,9 +258,11 @@
#
# - 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
# - 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-signup: false
# require-login: false
# enable-login: false
# enable-reservations: false

View File

@@ -449,6 +449,7 @@ type apiConfigResponse struct {
BaseURL string `json:"base_url"`
AppRoot string `json:"app_root"`
EnableLogin bool `json:"enable_login"`
RequireLogin bool `json:"require_login"`
EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"`

View File

@@ -1066,7 +1066,7 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha
var err error = nil
if hashed {
hash = password
if err := ValidPasswordHash(hash); err != nil {
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err
}
} else {
@@ -1434,7 +1434,7 @@ func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed
var err error
if hashed {
hash = password
if err := ValidPasswordHash(hash); err != nil {
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err
}
} else {

View File

@@ -1162,7 +1162,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
// Re-open the DB (second app start)
require.Nil(t, a.db.Close())
conf.Users = []*User{
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
}
conf.Access = map[string][]*Grant{
"philuser": {
@@ -1292,7 +1292,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
// Re-open the DB (second app start)
require.Nil(t, a.db.Close())
conf.Users = []*User{
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
}
conf.Access = map[string][]*Grant{
"philuser": {
@@ -1308,7 +1308,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
require.Len(t, users, 2)
require.Equal(t, "philuser", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
require.Equal(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
require.True(t, users[0].Provisioned) // Updated to provisioned!
grants, err = a.Grants("philuser")

View File

@@ -249,7 +249,8 @@ var (
ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists")
ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate")
ErrPasswordHashInvalid = errors.New("password hash must be a bcrypt hash, use 'ntfy user hash' to generate")
ErrPasswordHashWeak = errors.New("password hash too weak, use 'ntfy user hash' to generate")
ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number not found")

View File

@@ -41,10 +41,16 @@ func AllowedTier(tier string) bool {
}
// ValidPasswordHash checks if the given password hash is a valid bcrypt hash
func ValidPasswordHash(hash string) error {
func ValidPasswordHash(hash string, minCost int) error {
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
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
}

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
import routes from "./routes";
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
import { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
@@ -370,7 +370,7 @@ const PhoneNumbers = () => {
};
const handleCopy = (phoneNumber) => {
navigator.clipboard.writeText(phoneNumber);
copyToClipboard(phoneNumber);
setSnackOpen(true);
};
@@ -841,7 +841,7 @@ const TokensTable = (props) => {
};
const handleCopy = async (token) => {
await navigator.clipboard.writeText(token);
copyToClipboard(token);
setSnackOpen(true);
};

View File

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

View File

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

View File

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