diff --git a/cmd/user.go b/cmd/user.go index 2e5af5f4..9e33e1da 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -8,11 +8,13 @@ import ( "fmt" "os" "strings" + "time" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" + "heckel.io/ntfy/v2/mail" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -32,12 +34,17 @@ var flagsUser = 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-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: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), + 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-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), ) var cmdUser = &cli.Command{ Name: "user", Usage: "Manage/show users", - UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", + UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...", Flags: flagsUser, Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), Category: categoryServer, @@ -98,6 +105,30 @@ Example: You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass directly the bcrypt hash. This is useful if you are updating users via scripts. +`, + }, + { + Name: "reset-pass", + Aliases: []string{"rp"}, + Usage: "Generates a password reset link for a user", + UsageText: "ntfy user reset-pass [--send-email] USERNAME", + Action: execUserResetPass, + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"}, + }, + Description: `Generate a password reset link for the given user and print it to stdout. + +The user completes the reset by opening the link in a browser and choosing a new password; +the admin never learns or chooses the new password. The link is single-use and expires after +one hour. This is an admin override of the self-service reset flow -- unlike self-service, it +does not require the user to have a verified primary email (the token is bound to the user). + +With --send-email, the link is additionally emailed to the user's primary email address (this +requires SMTP to be configured and the user to have a verified primary email). + +Example: + ntfy user reset-pass phil # Print a reset link for user phil + ntfy user reset-pass --send-email phil # Print and email the reset link `, }, { @@ -257,7 +288,6 @@ func execUserDel(c *cli.Context) error { func execUserChangePass(c *cli.Context) error { username := c.Args().Get(0) password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH") - if !hashed { password = os.Getenv("NTFY_PASSWORD") } @@ -286,6 +316,61 @@ func execUserChangePass(c *cli.Context) error { return nil } +func execUserResetPass(c *cli.Context) error { + username := c.Args().Get(0) + sendEmail := c.Bool("send-email") + baseURL := strings.TrimSuffix(c.String("base-url"), "/") + if username == "" { + return errors.New("username expected, type 'ntfy user reset-pass --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } else if baseURL == "" { + return errors.New("base-url must be configured to generate a reset link") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if errors.Is(err, user.ErrUserNotFound) { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } else if u.Provisioned { + return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username) + } + // Resolve the primary email up front if we need to send -- fail before creating a token + var primaryEmail string + if sendEmail { + primaryEmail, err = manager.PrimaryEmail(u.ID) + if err != nil { + return err + } else if primaryEmail == "" { + return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username) + } + } + // The reset token is bound to the user, not an email -- so this works even with no SMTP + token, err := manager.AddMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour) + if err != nil { + return err + } + link := baseURL + "/account/password/reset/" + token + fmt.Fprintln(c.App.Writer, link) + if sendEmail { + sender := mail.NewSender(&mail.Config{ + SMTPAddr: c.String("smtp-sender-addr"), + SMTPUser: c.String("smtp-sender-user"), + SMTPPass: c.String("smtp-sender-pass"), + From: c.String("smtp-sender-from"), + }) + if err := sender.SendPasswordReset(primaryEmail, link); err != nil { + return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err) + } + fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail) + } + return nil +} + func execUserChangeRole(c *cli.Context) error { username := c.Args().Get(0) role := user.Role(c.Args().Get(1)) @@ -313,7 +398,7 @@ func execUserHash(c *cli.Context) error { if err != nil { return err } - hash, err := user.HashPassword(password) + hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } diff --git a/cmd/user_test.go b/cmd/user_test.go index a6250b72..bde0fa1f 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -122,6 +122,69 @@ func TestCLI_User_Delete(t *testing.T) { require.Contains(t, err.Error(), "user phil does not exist") } +func TestCLI_User_ResetPass(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + // Prints a working-looking reset link when base-url is set + app, _, stdout, _ := newTestApp() + require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil")) + require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/") +} + +func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "reset-pass", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "base-url") +} + +func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + // --send-email requires a primary email; phil has none + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "no primary email") +} + +func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + // Seed a provisioned user into the auth database via config provisioning + m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{ + ProvisionEnabled: true, + Users: []*user.User{ + {Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser}, + }, + }) + require.Nil(t, err) + require.Nil(t, m.Close()) + + app, _, _, _ := newTestApp() + err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser") + require.Error(t, err) + require.Contains(t, err.Error(), "provisioned") +} + func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { configFile := filepath.Join(t.TempDir(), "server-dummy.yml") require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml diff --git a/docs/config.md b/docs/config.md index bc241a4f..0f72557b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1047,9 +1047,12 @@ configured for `ntfy.sh`): ``` By default, any user (including anonymous users) can send email notifications to any address. To require email -address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, -and authenticated users can only send to email addresses they have verified in their account settings. Users can -also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address. +address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and +authenticated users can only send to *literal* email addresses they have verified in their account settings. + +Regardless of this setting, a logged-in user can pass `yes`/`true`/`1` as the `X-Email` value to send to their primary +verified address (falling back to their first verified address if no primary is designated). `smtp-sender-verify` only +governs whether arbitrary literal addresses are allowed. Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. diff --git a/docs/privacy.md b/docs/privacy.md index 055e3a35..322e4f34 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,6 +1,6 @@ # Privacy policy -**Last updated:** March 31, 2026 +**Last updated:** June 15, 2026 This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information when you use the ntfy.sh service, web app, and mobile applications (Android and iOS). @@ -19,8 +19,9 @@ If you create an account on ntfy.sh, we collect: - **Username** - A unique identifier you choose - **Password** - Stored as a secure bcrypt hash (we never store your plaintext password) -- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified - email address for use with the email notification feature +- **Email address** - If you add an email address to your account for account recovery and password resets, for use + with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email + addresses you add to your account are verified by sending a confirmation link. - **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call) You can use ntfy without creating an account. Anonymous usage is fully supported. @@ -77,9 +78,10 @@ Your phone number is shared with Twilio to deliver these services. Twilio's ### Amazon SES (email delivery) -If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to -deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure. -Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies. +If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email +address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The +recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's +[privacy policy](https://aws.amazon.com/privacy/) applies. ### Stripe (payments) diff --git a/docs/publish.md b/docs/publish.md index 0060cdcc..46f2d80a 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3251,8 +3251,13 @@ You can forward messages to e-mail by specifying an address in the header. This you'd like to persist longer, or to blast-notify yourself on all possible channels. Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`). -Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled), -you can also pass `yes`, `true`, or `1` to send to your first verified email address. +Only one e-mail address is supported. + +If you are logged in and have a verified email address on your account, you can pass `yes`, `true`, or `1` instead of an +address to send to your **primary email address** (the one marked primary in the web app's +[Account section](https://ntfy.sh/account)); if you haven't designated a primary, it falls back to your first verified +address. This works regardless of the [`smtp-sender-verify`](config.md#e-mail-notifications) setting -- that setting only +controls whether *literal* addresses must already be verified on your account. ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of @@ -3702,7 +3707,7 @@ all the supported fields: | `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | | `filename` | - | *string* | `file.jpg` | File name of the attachment | | `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | -| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address | +| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use your primary verified address | | `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | | `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | diff --git a/docs/releases.md b/docs/releases.md index 5af903d7..2b97796d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1948,6 +1948,27 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.25.0 (UNRELEASED) + +This release adds **password reset** via email, and reworks email verification to use durable, +link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at +signup; a user can reset their password only once they have a verified "primary" (recovery) +email. + +All of this work is probably not useful for self-hosters, but it hopefully will be useful for me, +since I do have to reset emails on a regular basis. + +**Features:** + +* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins +* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI + +**Bug fixes + maintenance:** + +* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG +* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address +* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution) + ### ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out diff --git a/go.mod b/go.mod index 8d71ca73..97ee718c 100644 --- a/go.mod +++ b/go.mod @@ -4,22 +4,22 @@ go 1.25.8 require ( cloud.google.com/go/firestore v1.22.0 // indirect - cloud.google.com/go/storage v1.62.2 // indirect + cloud.google.com/go/storage v1.62.3 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/emersion/go-smtp v0.24.0 github.com/gabriel-vasile/mimetype v1.4.13 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.47 github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 - golang.org/x/crypto v0.52.0 + golang.org/x/crypto v0.53.0 golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 - golang.org/x/term v0.43.0 + golang.org/x/sync v0.21.0 + golang.org/x/term v0.44.0 golang.org/x/time v0.15.0 - google.golang.org/api v0.283.0 + google.golang.org/api v0.286.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -34,8 +34,8 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/sys v0.45.0 - golang.org/x/text v0.37.0 + golang.org/x/sys v0.46.0 + golang.org/x/text v0.38.0 ) require ( @@ -48,9 +48,9 @@ require ( cloud.google.com/go/longrunning v1.0.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -60,7 +60,7 @@ require ( github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -79,10 +79,10 @@ require ( 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/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.68.1 // indirect + github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -94,11 +94,11 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 8ba2d94d..baa653f9 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMh cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= -cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0= +cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU= @@ -28,14 +28,14 @@ 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/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= 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/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= @@ -66,8 +66,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -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.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -120,8 +120,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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -139,16 +139,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= -github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= -github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= 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/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -195,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 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.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -211,8 +211,8 @@ 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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -222,8 +222,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.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -236,8 +236,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.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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -247,8 +247,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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -260,8 +260,8 @@ 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.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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -274,16 +274,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= -google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI= +google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= 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/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE= -google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/server/smtp_sender.go b/mail/format.go similarity index 58% rename from server/smtp_sender.go rename to mail/format.go index 885f806b..e68ec8ea 100644 --- a/server/smtp_sender.go +++ b/mail/format.go @@ -1,4 +1,4 @@ -package server +package mail import ( _ "embed" // required by go:embed @@ -6,66 +6,24 @@ import ( "fmt" "mime" "strings" - "sync" "time" - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/mail" "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/util" ) -type mailer interface { - Send(v *visitor, m *model.Message, to string) error - Counts() (total int64, success int64, failure int64) -} +var ( + //go:embed "mailer_emoji_map.json" + emojisJSON string -type smtpSender struct { - config *Config - sender *mail.Sender - success int64 - failure int64 - mu sync.Mutex -} + // emojiMap maps ntfy tag names to emoji, parsed once from the embedded JSON in init + emojiMap map[string]string +) -func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error { - return s.withCount(v, m, func() error { - message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m) - if err != nil { - return err - } - ev := logvm(v, m). - Tag(tagEmail). - Fields(log.Context{ - "email_via": s.sender.Addr(), - "email_user": s.sender.User(), - "email_to": to, - }) - if ev.IsTrace() { - ev.Field("email_body", message).Trace("Sending email") - } - ev.Info("Sending email") - return s.sender.SendRaw(to, []byte(message)) - }) -} - -func (s *smtpSender) Counts() (total int64, success int64, failure int64) { - s.mu.Lock() - defer s.mu.Unlock() - return s.success + s.failure, s.success, s.failure -} - -func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error { - err := fn() - s.mu.Lock() - defer s.mu.Unlock() - if err != nil { - logvm(v, m).Err(err).Debug("Sending mail failed") - s.failure++ - } else { - s.success++ +func init() { + if err := json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { + panic("mail: invalid embedded emoji map: " + err.Error()) } - return err } func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) { @@ -78,10 +36,7 @@ func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, e message := m.Message trailer := "" if len(m.Tags) > 0 { - emojis, tags, err := toEmojis(m.Tags) - if err != nil { - return "", err - } + emojis, tags := toEmojis(m.Tags) if len(emojis) > 0 { subject = strings.Join(emojis, " ") + " " + subject } @@ -126,16 +81,7 @@ This message was sent by {ip} at {time} via {topicURL}` return body, nil } -var ( - //go:embed "mailer_emoji_map.json" - emojisJSON string -) - -func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) { - var emojiMap map[string]string - if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { - return nil, nil, err - } +func toEmojis(tags []string) (emojisOut []string, tagsOut []string) { tagsOut = make([]string, 0) emojisOut = make([]string, 0) for _, t := range tags { diff --git a/server/smtp_sender_test.go b/mail/format_test.go similarity index 99% rename from server/smtp_sender_test.go rename to mail/format_test.go index 4f97b128..4c4c4613 100644 --- a/server/smtp_sender_test.go +++ b/mail/format_test.go @@ -1,4 +1,4 @@ -package server +package mail import ( "testing" diff --git a/server/mailer_emoji_map.json b/mail/mailer_emoji_map.json similarity index 100% rename from server/mailer_emoji_map.json rename to mail/mailer_emoji_map.json diff --git a/mail/sender.go b/mail/sender.go index 5511cb04..7e0c4063 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -10,82 +10,94 @@ import ( "time" "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/util" + "heckel.io/ntfy/v2/model" ) const ( - verifyCodeExpiry = 10 * time.Minute - verifyCodeLength = 6 - verifyCodeSubject = "ntfy email verification" + tagMail = "mail" + + emailVerificationSubject = "Verify your email for ntfy" + passwordResetSubject = "Reset your ntfy password" ) // Config holds the SMTP configuration for the mail sender type Config struct { + BaseURL string // ntfy base URL, used to build topic URLs in notification emails SMTPAddr string // SMTP server address (host:port) SMTPUser string // SMTP auth username SMTPPass string // SMTP auth password From string // Sender email address } -// Sender sends emails and manages email verification codes -type Sender struct { - config *Config - codes map[string]verifyCode // Verification codes, keyed by email - mu sync.Mutex - closeChan chan struct{} +// Sender sends all of ntfy's outgoing email: notification emails (the email-on-publish feature) +// as well as the magic-link emails for email verification and password reset. realSender is the +// SMTP-backed implementation; tests inject a fake. +type Sender interface { + SendNotification(to string, m *model.Message, senderIP string) error + NotificationCounts() (total int64, success int64, failure int64) + SendEmailVerification(to, link string) error + SendPasswordReset(to, link string) error } -type verifyCode struct { - code string - expires time.Time +// realSender is the SMTP-backed implementation of Sender. Pending verification/reset state lives +// in the database (see user.Manager), not in this struct. +type realSender struct { + config *Config + success int64 + failure int64 + mu sync.Mutex } // NewSender creates a new mail Sender with the given SMTP config -func NewSender(config *Config) *Sender { - s := &Sender{ - config: config, - codes: make(map[string]verifyCode), - closeChan: make(chan struct{}), - } - go s.expireLoop() - return s +func NewSender(config *Config) Sender { + return &realSender{config: config} } -// Close stops the background expiry loop -func (s *Sender) Close() { - close(s.closeChan) -} - -// Addr returns the SMTP server address -func (s *Sender) Addr() string { - return s.config.SMTPAddr -} - -// User returns the SMTP username -func (s *Sender) User() string { - return s.config.SMTPUser -} - -// From returns the sender email address -func (s *Sender) From() string { - return s.config.From -} - -// SendRaw sends a raw email message via SMTP -func (s *Sender) SendRaw(to string, message []byte) error { - host, _, err := net.SplitHostPort(s.config.SMTPAddr) +// SendNotification formats a ntfy message into a notification email and sends it via SMTP. It +// tracks success/failure counts, exposed via Counts (used for the server stats). +func (s *realSender) SendNotification(to string, m *model.Message, senderIP string) error { + message, err := formatMail(s.config.BaseURL, senderIP, s.config.From, to, m) if err != nil { + s.count(false) return err } - var auth smtp.Auth - if s.config.SMTPUser != "" { - auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host) - } - return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message) + log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email") + err = s.sendRaw(to, []byte(message)) + s.count(err == nil) + return err } -// Send sends a plain text email via SMTP -func (s *Sender) Send(to, subject, body string) error { +// NotificationCounts returns the number of notification emails sent, broken down into total, success and failure +func (s *realSender) NotificationCounts() (total int64, success int64, failure int64) { + s.mu.Lock() + defer s.mu.Unlock() + return s.success + s.failure, s.success, s.failure +} + +// SendEmailVerification sends an email containing a magic link to verify ownership of the +// recipient address. The link carries a one-time token validated against the database. +func (s *realSender) SendEmailVerification(to, link string) error { + body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account: + +%s + +This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link) + return s.send(to, emailVerificationSubject, body) +} + +// SendPasswordReset sends an email containing a magic link to set a new password. The link +// carries a one-time token validated against the database. +func (s *realSender) SendPasswordReset(to, link string) error { + body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account: + +%s + +This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link) + return s.send(to, passwordResetSubject, body) +} + +// send sends a plain text email via SMTP +func (s *realSender) send(to, subject, body string) error { date := time.Now().UTC().Format(time.RFC1123Z) encodedSubject := mime.BEncoding.Encode("utf-8", subject) message := `From: ntfy <{from}> @@ -100,55 +112,29 @@ Content-Type: text/plain; charset="utf-8" message = strings.ReplaceAll(message, "{date}", date) message = strings.ReplaceAll(message, "{subject}", encodedSubject) message = strings.ReplaceAll(message, "{body}", body) - log.Tag("mail").Field("email_to", to).Debug("Sending email") - return s.SendRaw(to, []byte(message)) + log.Tag(tagMail).Field("email_to", to).Debug("Sending email") + return s.sendRaw(to, []byte(message)) } -// SendVerification generates a random code, stores it in-memory, and sends a verification email -func (s *Sender) SendVerification(to string) error { - code := util.RandomString(verifyCodeLength) - s.mu.Lock() - s.codes[to] = verifyCode{ - code: code, - expires: time.Now().Add(verifyCodeExpiry), +// sendRaw sends a raw email message via SMTP +func (s *realSender) sendRaw(to string, message []byte) error { + host, _, err := net.SplitHostPort(s.config.SMTPAddr) + if err != nil { + return err } - s.mu.Unlock() - body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code) - return s.Send(to, verifyCodeSubject, body) + var auth smtp.Auth + if s.config.SMTPUser != "" { + auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host) + } + return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message) } -// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success. -func (s *Sender) CheckVerification(email, code string) bool { +func (s *realSender) count(ok bool) { s.mu.Lock() defer s.mu.Unlock() - vc, ok := s.codes[email] - if !ok || time.Now().After(vc.expires) || vc.code != code { - return false - } - delete(s.codes, email) - return true -} - -func (s *Sender) expireLoop() { - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for { - select { - case <-ticker.C: - s.expireVerificationCodes() - case <-s.closeChan: - return - } - } -} - -func (s *Sender) expireVerificationCodes() { - s.mu.Lock() - defer s.mu.Unlock() - now := time.Now() - for email, vc := range s.codes { - if now.After(vc.expires) { - delete(s.codes, email) - } + if ok { + s.success++ + } else { + s.failure++ } } diff --git a/server/config.go b/server/config.go index b7dadddf..de94b4ca 100644 --- a/server/config.go +++ b/server/config.go @@ -71,7 +71,7 @@ const ( DefaultVisitorEmailLimitReplenish = time.Hour DefaultVisitorTopicCreationLimitBurst = 100 DefaultVisitorTopicCreationLimitReplenish = time.Minute - DefaultVisitorAccountCreationLimitBurst = 3 + DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket) DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 DefaultVisitorAuthFailureLimitReplenish = time.Minute diff --git a/server/errors.go b/server/errors.go index 3197d6ca..0b0f790d 100644 --- a/server/errors.go +++ b/server/errors.go @@ -143,9 +143,10 @@ var ( errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil} errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} - errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil} + errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil} errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} + errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} @@ -156,6 +157,7 @@ var ( 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} errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil} + errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", 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} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} @@ -165,7 +167,7 @@ var ( errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit + errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit diff --git a/server/server.go b/server/server.go index c380bd26..fb5d5d37 100644 --- a/server/server.go +++ b/server/server.go @@ -57,8 +57,7 @@ type Server struct { unixListener net.Listener smtpServer *smtp.Server smtpServerBackend *smtpBackend - smtpSender mailer - mailSender *mail.Sender + mailer mail.Sender topics map[string]*topic visitors map[string]*visitor // ip: or user: firebaseClient *firebaseClient @@ -93,8 +92,13 @@ var ( clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) sequenceIDRegex = topicRegex - webConfigPath = "/config.js" - webManifestPath = "/manifest.webmanifest" + webAppConfigPath = "/config.js" + webAppManifestPath = "/manifest.webmanifest" + webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended + webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app) + webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended + webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app) + accountPath = "/account" matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" @@ -116,6 +120,10 @@ var ( apiAccountPhoneVerifyPath = "/v1/account/phone/verify" apiAccountEmailPath = "/v1/account/email" apiAccountEmailVerifyPath = "/v1/account/email/verify" + apiAccountEmailPrimaryPath = "/v1/account/email/primary" + apiAccountEmailResendPath = "/v1/account/email/resend" + apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request" + apiAccountPasswordResetPath = "/v1/account/password/reset" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -176,16 +184,15 @@ const ( // New instantiates a new Server. It creates the cache and adds a Firebase // subscriber (if configured). func New(conf *Config) (*Server, error) { - var mailer mailer - var mailSender *mail.Sender + var sender mail.Sender if conf.SMTPSenderAddr != "" { - mailSender = mail.NewSender(&mail.Config{ + sender = mail.NewSender(&mail.Config{ + BaseURL: conf.BaseURL, SMTPAddr: conf.SMTPSenderAddr, SMTPUser: conf.SMTPSenderUser, SMTPPass: conf.SMTPSenderPass, From: conf.SMTPSenderFrom, }) - mailer = &smtpSender{config: conf, sender: mailSender} } var stripe stripeAPI if payments.Available && conf.StripeSecretKey != "" { @@ -290,8 +297,7 @@ func New(conf *Config) (*Server, error) { webPush: wp, attachment: attachmentStore, firebaseClient: firebaseClient, - smtpSender: mailer, - mailSender: mailSender, + mailer: sender, topics: topics, userManager: userManager, messages: messages, @@ -443,9 +449,6 @@ func (s *Server) Stop() { if s.smtpServer != nil { s.smtpServer.Close() } - if s.mailSender != nil { - s.mailSender.Close() - } if s.attachment != nil { s.attachment.Close() } @@ -542,7 +545,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" { - return s.ensureWebEnabled(s.handleRoot)(w, r, v) + return s.ensureWebEnabled(s.handleWebApp)(w, r, v) } else if r.Method == http.MethodHead && r.URL.Path == "/" { return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { @@ -551,9 +554,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureAdmin(s.handleVersion)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath { return s.handleConfig(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { + } else if r.Method == http.MethodGet && r.URL.Path == webAppConfigPath { return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { + } else if r.Method == http.MethodGet && r.URL.Path == webAppManifestPath { return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath { return s.ensureAdmin(s.handleUsersGet)(w, r, v) @@ -611,12 +614,20 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v) } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v) - } else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailVerifyPath { - return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v) } else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath { return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailVerifyPath { + return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountEmailVerify))(w, r, v) // No ensureUser: clicked from a mail client, possibly logged out } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath { return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailPrimaryPath { + return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath { + return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetRequestPath { + return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordResetRequest))(w, r, v) // Unauthenticated + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetPath { + return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordReset))(w, r, v) // Unauthenticated } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path { return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v) } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path { @@ -659,17 +670,30 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v) } else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v) + } else if r.Method == http.MethodGet && (webAppEmailVerifyRegex.MatchString(r.URL.Path) || webAppPasswordResetRegex.MatchString(r.URL.Path)) { + return s.ensureWebEnabled(s.handleWebAppNoIndex)(w, r, v) // Magic-link landing pages (client-side routes) } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) { return s.ensureWebEnabled(s.handleTopic)(w, r, v) } return errHTTPNotFound } -func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error { +// handleWebApp serves the embedded web app's index for client-side (SPA) routes that the +// browser router resolves, so the app shell loads and the client-side router takes over. +func (s *Server) handleWebApp(w http.ResponseWriter, r *http.Request, v *visitor) error { r.URL.Path = webAppIndex return s.handleStatic(w, r, v) } +// handleWebAppNoIndex serves the web app index for the magic-link landing pages, whose path +// carries a one-time token. The response is marked no-referrer (so the token can't leak to third +// parties via the Referer header) and noindex (so it never gets indexed). +func (s *Server) handleWebAppNoIndex(w http.ResponseWriter, r *http.Request, v *visitor) error { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Robots-Tag", "noindex") + return s.handleWebApp(w, r, v) +} + func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error { unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too! if unifiedpush { @@ -678,8 +702,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) _, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n") return err } - r.URL.Path = webAppIndex - return s.handleStatic(w, r, v) + return s.handleWebApp(w, r, v) } func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error { @@ -715,21 +738,21 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi func (s *Server) configResponse() *apiConfigResponse { return &apiConfigResponse{ - 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 != "", - EnableEmails: s.config.SMTPSenderFrom != "", - EnableEmailVerify: s.config.SMTPSenderVerify, - EnableReservations: s.config.EnableReservations, - EnableWebPush: s.config.WebPushPublicKey != "", - BillingContact: s.config.BillingContact, - WebPushPublicKey: s.config.WebPushPublicKey, - DisallowedTopics: s.config.DisallowedTopics, - ConfigHash: s.config.Hash(), + 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 != "", + EnableEmails: s.config.SMTPSenderFrom != "", + EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url + EnableReservations: s.config.EnableReservations, + EnableWebPush: s.config.WebPushPublicKey != "", + BillingContact: s.config.BillingContact, + WebPushPublicKey: s.config.WebPushPublicKey, + DisallowedTopics: s.config.DisallowedTopics, + ConfigHash: s.config.Hash(), } } @@ -946,7 +969,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess if s.firebaseClient != nil && firebase { go s.sendToFirebase(v, m) } - if s.smtpSender != nil && email != "" { + if s.mailer != nil && email != "" { go s.sendEmail(v, m, email) } if s.config.TwilioAccount != "" && call != "" { @@ -1108,7 +1131,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) { func (s *Server) sendEmail(v *visitor, m *model.Message, email string) { logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email) - if err := s.smtpSender.Send(v, m, email); err != nil { + if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil { logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) minc(metricEmailsPublishedFailure) return @@ -1208,7 +1231,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) { return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid } - if s.smtpSender == nil && email != "" { + if s.mailer == nil && email != "" { return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") diff --git a/server/server_account.go b/server/server_account.go index 7c5c03c3..e28df08d 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -15,8 +15,10 @@ import ( ) const ( - syncTopicAccountSyncEvent = "sync" - tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much + syncTopicAccountSyncEvent = "sync" + tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much + emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification + passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter) ) func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -27,14 +29,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * } else if u != nil { return errHTTPUnauthorized // Cannot create account from user context } - if !v.AccountCreationAllowed() { - return errHTTPTooManyRequestsLimitAccountCreation + if !v.AccountActionAllowed() { + return errHTTPTooManyRequestsLimitAccountActions } } newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } + if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) { + return errHTTPBadRequestEmailAddressInvalid + } if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil { return errHTTPConflictUserExists } @@ -45,7 +50,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * } return err } - v.AccountCreated() + v.AccountActionPerformed() + // If an email was provided and email sending is configured, start verification (best-effort). + // The address becomes the primary email on verify (the new account has no primary yet); a + // failure to send must not fail signup, so we only log it. + if newAccount.Email != "" && s.mailer != nil { + if u, err := s.userManager.User(newAccount.Username); err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification") + } else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification") + } + } return s.writeJSON(w, newSuccessResponse()) } @@ -160,13 +175,29 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis response.PhoneNumbers = phoneNumbers } } - if s.mailSender != nil { + if s.mailer != nil { emails, err := s.userManager.Emails(u.ID) if err != nil { return err } - if len(emails) > 0 { - response.Emails = emails + primaryEmail, err := s.userManager.PrimaryEmail(u.ID) + if err != nil { + return err + } + pendingEmails, err := s.userManager.PendingEmails(u.ID) + if err != nil { + return err + } + // Combine verified (with primary flag) and pending (unverified) into one list + emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails)) + for _, email := range emails { + emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Primary: email == primaryEmail}) + } + for _, email := range pendingEmails { + emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true}) + } + if len(emailInfos) > 0 { + response.Emails = emailInfos } } } else { @@ -615,83 +646,254 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { +// handleAccountEmailAdd starts email verification (PUT /v1/account/email): it generates a +// magic-link token, stores a pending verification, and emails the link. The address is NOT +// added to the verified list until the user clicks the link (handleAccountEmailVerify). +func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid } - // Check user is allowed to add emails - if u == nil { - return errHTTPUnauthorized - } else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 { + // Check user is allowed to add emails (the tier email limit gates the feature) + if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 { return errHTTPUnauthorized } else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 { return errHTTPUnauthorized } - // Check if email already exists + // Reject if already verified on this account (pending re-requests are fine -- they replace) emails, err := s.userManager.Emails(u.ID) if err != nil { return err } else if util.Contains(emails, req.Email) { return errHTTPConflictEmailExists } - // Check email rate limit (counts against the user's email quota) + // Rate limit (counts against the user's email quota) if !v.EmailAllowed() { return errHTTPTooManyRequestsLimitEmails } - // Send verification email - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification") - if err := s.mailSender.SendVerification(req.Email); err != nil { + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification") + if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { +// handleAccountEmailVerify performs verification from the (unauthenticated) landing page +// (POST /v1/account/email/verify): it validates the raw token, adds the address to the user's +// verified emails, and -- if the user has no primary yet -- promotes it. No auth is required; +// the token binds the action to a user, so the click works from a logged-out mail client. +func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if req.Token == "" { + return errHTTPBadRequestEmailVerificationLinkInvalid + } + m, err := s.userManager.VerifyEmail(req.Token) + if errors.Is(err, user.ErrMagicLinkNotFound) { + return errHTTPBadRequestEmailVerificationLinkInvalid + } else if err != nil { + return err + } + logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified") + // Refresh the verified user's other sessions. The request is unauthenticated (v.User() is + // usually nil), so resolve the user from the token row and publish to their sync topic. + s.publishSyncEventForUserIDAsync(v, m.UserID) + return s.writeJSON(w, newSuccessResponse()) +} + +// handleAccountEmailDelete removes an email address, whether verified or still pending +// (DELETE /v1/account/email). Removing the primary leaves the account with no primary. +func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid - } else if !s.mailSender.CheckVerification(req.Email, req.Code) { - return errHTTPBadRequestEmailVerificationCodeInvalid } - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified") - if err := s.userManager.AddEmail(u.ID, req.Email); err != nil { - return err - } - return s.writeJSON(w, newSuccessResponse()) -} - -func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() - req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false) - if err != nil { - return err - } - if !emailAddressRegex.MatchString(req.Email) { - return errHTTPBadRequestEmailAddressInvalid - } - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email") + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)") if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil { return err } + // Also drop any pending verification for the address (no-op if there is none) + if err := s.userManager.DeleteEmailVerification(u.ID, req.Email); err != nil { + return err + } return s.writeJSON(w, newSuccessResponse()) } -// convertEmailAddress checks the email address against the user's verified email list. -// If smtp-sender-verify is false (default), the email is passed through as-is for -// backwards compatibility. If true, the user must be authenticated and the email must be -// in their verified list. "yes"/"true"/"1" resolves to the first verified email. -func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) { - if !s.config.SMTPSenderVerify { - if toBool(email) { - return "", errHTTPBadRequestEmailAddressInvalid +// handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery) +// email (POST /v1/account/email/primary). +func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if !emailAddressRegex.MatchString(req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email") + err = s.userManager.SetPrimaryEmail(u.ID, req.Email) + if errors.Is(err, user.ErrEmailPrimaryElsewhere) { + return errHTTPConflictEmailPrimaryElsewhere + } else if errors.Is(err, user.ErrEmailNotFound) { + return errHTTPBadRequestEmailAddressNotVerified + } else if err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +// handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend). +func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if !emailAddressRegex.MatchString(req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + // Only resend for an address that is actually pending on this account + pending, err := s.userManager.PendingEmails(u.ID) + if err != nil { + return err + } else if !util.Contains(pending, req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + if !v.EmailAllowed() { + return errHTTPTooManyRequestsLimitEmails + } + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification") + if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +// enqueueEmailVerification generates a magic-link token for the given address, stores the +// pending verification (replacing any existing one), and emails the link. Shared by the add, +// resend, signup, and Stripe paths. Requires base-url to build an absolute link. +func (s *Server) enqueueEmailVerification(userID, email string) error { + if s.config.BaseURL == "" { + return errHTTPInternalErrorMissingBaseURL + } + token, err := s.userManager.AddMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry) + if err != nil { + return err + } + link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token + return s.mailer.SendEmailVerification(email, link) +} + +// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request, +// unauthenticated). It resolves the identifier (username or primary email) to at most one account +// and emails a reset link to that account's primary email. The response is always a uniform 200, +// regardless of whether anything matched, so it cannot be used to probe for accounts. +func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + // Rate limit via the shared per-visitor account-creation bucket (no new limiter/config) + if !v.AccountActionAllowed() { + return errHTTPTooManyRequestsLimitAccountActions + } + v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing + identifier := strings.TrimSpace(req.Identifier) + if identifier != "" && s.config.BaseURL != "" { + if userID, email, ok := s.resolveResetPasswordTarget(identifier); ok { + token, err := s.userManager.AddMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry) + if err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token") + } else { + link := s.config.BaseURL + webAppPasswordResetPathPrefix + token + logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link") + if err := s.mailer.SendPasswordReset(email, link); err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email") + } + } + } else { + logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)") } + } + return s.writeJSON(w, newSuccessResponse()) +} + +// resolveResetPasswordTarget resolves a reset identifier (username or primary email) to a single account +// and its primary email. It applies the reset policy on top of the lookup: provisioned users are +// excluded, and ok=false is returned unless the account has a verified primary email (reset +// requires one, and that is where the link is sent). +func (s *Server) resolveResetPasswordTarget(identifier string) (userID string, email string, ok bool) { + u, err := s.userManager.UserByEmailOrUsername(identifier) + if err != nil || u == nil || u.Provisioned { + return "", "", false + } + primary, err := s.userManager.PrimaryEmail(u.ID) + if err != nil || primary == "" { + return "", "", false + } + return u.ID, primary, true +} + +// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated): +// it validates the token and sets the new password. Existing access tokens stay valid. +func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if req.Token == "" { + return errHTTPBadRequestResetLinkInvalid + } else if req.Password == "" { + return errHTTPBadRequest + } + err = s.userManager.ResetPassword(req.Token, req.Password) + if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) { + return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that) + } else if err != nil { + return err + } + logvr(v, r).Tag(tagAccount).Info("Password reset performed") + return s.writeJSON(w, newSuccessResponse()) +} + +// convertEmailAddress resolves the X-Email value to the address ntfy should send to. +// +// "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is +// designated (e.g. a provisioned user), the first verified address (alphabetically). This is +// independent of smtp-sender-verify: it only requires an authenticated user with a verified +// address, since it means "send to my own email". +// +// A literal address is sent as-is when smtp-sender-verify is false (the default, backwards +// compatible); when true, the address must be one the user has verified. +func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) { + if toBool(email) { + if u == nil { + return "", errHTTPBadRequestAnonymousEmailNotAllowed + } else if s.userManager == nil { + return "", errHTTPBadRequestEmailAddressNotVerified + } + primary, err := s.userManager.PrimaryEmail(u.ID) + if err != nil { + return "", errHTTPInternalError + } else if primary != "" { + return primary, nil + } + // No primary designated -> fall back to the first verified address, if any + emails, err := s.userManager.Emails(u.ID) + if err != nil { + return "", errHTTPInternalError + } else if len(emails) > 0 { + return emails[0], nil + } + return "", errHTTPBadRequestEmailAddressNotVerified + } + // A literal address + if !s.config.SMTPSenderVerify { return email, nil } else if u == nil { return "", errHTTPBadRequestAnonymousEmailNotAllowed @@ -701,11 +903,6 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT emails, err := s.userManager.Emails(u.ID) if err != nil { return "", errHTTPInternalError - } else if len(emails) == 0 { - return "", errHTTPBadRequestEmailAddressNotVerified - } - if toBool(email) { - return emails[0], nil } else if util.Contains(emails, email) { return email, nil } @@ -721,9 +918,30 @@ func (s *Server) publishSyncEventAsync(v *visitor) { }() } -// publishSyncEvent publishes a sync message to the user's sync topic +// publishSyncEvent publishes a sync message to the authenticated user's sync topic func (s *Server) publishSyncEvent(v *visitor) error { - u := v.User() + return s.publishSyncEventForUser(v, v.User()) +} + +// publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the +// given ID, resolving the user first. Used by the unauthenticated email-verify handler, where +// the request visitor has no associated user but the token identifies the account to refresh. +func (s *Server) publishSyncEventForUserIDAsync(v *visitor, userID string) { + go func() { + u, err := s.userManager.UserByID(userID) + if err != nil { + logv(v).Err(err).Trace("Error loading user for sync event") + return + } + if err := s.publishSyncEventForUser(v, u); err != nil { + logv(v).Err(err).Trace("Error publishing to user's sync topic") + } + }() +} + +// publishSyncEventForUser publishes a sync message to the given user's sync topic, using v as +// the publishing visitor (for rate-limit accounting). No-op if the user has no sync topic. +func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error { if u == nil || u.SyncTopic == "" { return nil } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go new file mode 100644 index 00000000..793c89be --- /dev/null +++ b/server/server_account_email_test.go @@ -0,0 +1,452 @@ +package server + +import ( + "fmt" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +// captureMailer is a fake mailer that records the magic links it is asked to send, so tests can +// "click" them without a real SMTP server. The notification side is a no-op. +type captureMailer struct { + verifyLinks map[string]string // email -> verification link + resetLinks map[string]string // email -> reset link +} + +func newCaptureMailer() *captureMailer { + return &captureMailer{verifyLinks: map[string]string{}, resetLinks: map[string]string{}} +} + +func (c *captureMailer) SendEmailVerification(to, link string) error { + c.verifyLinks[to] = link + return nil +} + +func (c *captureMailer) SendPasswordReset(to, link string) error { + c.resetLinks[to] = link + return nil +} + +func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error { + return nil +} + +func (c *captureMailer) NotificationCounts() (total int64, success int64, failure int64) { + return 0, 0, 0 +} + +// newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured) +// and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth. +func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailer = mailer + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")} + return s, mailer, auth +} + +func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResponse { + rr := request(t, s, "GET", "/v1/account", "", auth) + require.Equal(t, 200, rr.Code) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + return account +} + +// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email +// list returned by GET /v1/account, so assertions stay readable. +func verifiedAddrs(account *apiAccountResponse) []string { + addrs := make([]string, 0) + for _, e := range account.Emails { + if !e.Pending { + addrs = append(addrs, e.Address) + } + } + return addrs +} + +func pendingAddrs(account *apiAccountResponse) []string { + addrs := make([]string, 0) + for _, e := range account.Emails { + if e.Pending { + addrs = append(addrs, e.Address) + } + } + return addrs +} + +func primaryAddr(account *apiAccountResponse) string { + for _, e := range account.Emails { + if e.Primary { + return e.Address + } + } + return "" +} + +func tokenFromLink(t *testing.T, link, prefix string) string { + require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix) + return strings.TrimPrefix(link, prefix) +} + +func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // Start verification + rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth) + require.Equal(t, 200, rr.Code) + + // Pending, not yet verified, no primary + account := getAccount(t, s, auth) + require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account)) + require.Empty(t, verifiedAddrs(account)) + require.Equal(t, "", primaryAddr(account)) + + // "Click" the captured link (unauthenticated POST) + token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") + rr = request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil) + require.Equal(t, 200, rr.Code) + + // Now verified + primary, no longer pending + account = getAccount(t, s, auth) + require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account)) + require.Equal(t, "ben@example.com", primaryAddr(account)) + require.Empty(t, pendingAddrs(account)) + }) +} + +func TestAccount_Email_VerifyInvalidToken(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, _, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account/email/verify", `{"token":"doesnotexist"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40051, toHTTPError(t, rr.Body.String()).Code) + + // Empty token also rejected + rr = request(t, s, "POST", "/v1/account/email/verify", `{"token":""}`, nil) + require.Equal(t, 400, rr.Code) + }) +} + +func TestAccount_Email_DeletePending(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, _, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) + require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth))) + + // Deleting the pending address clears it (no verification ever happened) + require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) + account := getAccount(t, s, auth) + require.Empty(t, pendingAddrs(account)) + require.Empty(t, verifiedAddrs(account)) + }) +} + +func TestAccount_Email_Resend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) + firstLink := mailer.verifyLinks["ben@example.com"] + require.NotEmpty(t, firstLink) + + // Resend issues a fresh link (the old one is replaced) + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/resend", `{"email":"ben@example.com"}`, auth).Code) + require.NotEqual(t, firstLink, mailer.verifyLinks["ben@example.com"]) + + // The old token no longer verifies; the new one does + oldToken := tokenFromLink(t, firstLink, "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, oldToken), nil).Code) + newToken := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, newToken), nil).Code) + + // Resending for a non-pending address is rejected + require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/resend", `{"email":"never@example.com"}`, auth).Code) + }) +} + +func TestAccount_Email_SetPrimaryCollision(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // ben verifies shared@ -> becomes his primary + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code) + benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code) + require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth))) + + // alice verifies the same address -> allowed as secondary, but it is not her primary + require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + aliceAuth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")} + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, aliceAuth).Code) + aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code) + aliceAccount := getAccount(t, s, aliceAuth) + require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount)) + require.Equal(t, "", primaryAddr(aliceAccount)) + + // alice trying to promote it to primary collides with ben's + rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth) + require.Equal(t, 409, rr.Code) + require.Equal(t, 40908, toHTTPError(t, rr.Body.String()).Code) + }) +} + +// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email. +func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) { + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code) + token := tokenFromLink(t, mailer.verifyLinks[email], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) +} + +// canLogin returns true if username/password authenticates (via the token-create endpoint). +func canLogin(t *testing.T, s *Server, username, password string) bool { + rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)}) + return rr.Code == 200 +} + +func TestAccount_PasswordReset_ByUsername(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + verifyEmailFor(t, s, mailer, auth, "ben@example.com") + + // Request reset by username + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil) + require.Equal(t, 200, rr.Code) + token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/") + + // Confirm with a new password + rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil) + require.Equal(t, 200, rr.Code) + + require.True(t, canLogin(t, s, "ben", "brandnew")) + require.False(t, canLogin(t, s, "ben", "ben")) + }) +} + +func TestAccount_PasswordReset_ByEmail(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + verifyEmailFor(t, s, mailer, auth, "ben@example.com") + + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil) + require.Equal(t, 200, rr.Code) + token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/") + rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil) + require.Equal(t, 200, rr.Code) + require.True(t, canLogin(t, s, "ben", "brandnew")) + }) +} + +func TestAccount_PasswordReset_EmailLookalikeUsernameDoesNotShadow(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // Account A (the email owner): user "ben" with verified primary email "phil@example.com" + verifyEmailFor(t, s, mailer, auth, "phil@example.com") + + // Account B (the squatter): a different account whose USERNAME looks like A's email, with + // its own, different verified primary email + require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false)) + squatter, err := s.userManager.User("phil@example.com") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com")) + + // Reset by the ambiguous identifier: the verified email must win over the look-alike username + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil) + require.Equal(t, 200, rr.Code) + require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A) + require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B) + + // The token resets account A (ben); the squatter's password is untouched + token := tokenFromLink(t, mailer.resetLinks["phil@example.com"], "https://ntfy.example.com/account/password/reset/") + rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil) + require.Equal(t, 200, rr.Code) + require.True(t, canLogin(t, s, "ben", "brandnew")) // account A was reset + require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected + }) +} + +func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // Unknown identifier still returns a uniform 200, and no email is sent + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil) + require.Equal(t, 200, rr.Code) + require.Empty(t, mailer.resetLinks) + }) +} + +func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // ben exists but has no verified primary email -> uniform 200, nothing sent + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil) + require.Equal(t, 200, rr.Code) + require.Empty(t, mailer.resetLinks) + }) +} + +func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.EnableSignup = true + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailer = mailer + defer s.closeDatabases() + + // Sign up with an optional email -> account created and a verification link sent + rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil) + require.Equal(t, 200, rr.Code) + link := mailer.verifyLinks["emma@example.com"] + require.NotEmpty(t, link) + + // Verifying the link makes it the (first) primary email + token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) + account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")}) + require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account)) + require.Equal(t, "emma@example.com", primaryAddr(account)) + }) +} + +func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.EnableSignup = true + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailer = mailer + defer s.closeDatabases() + + // No email -> account created, nothing sent + require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code) + require.Empty(t, mailer.verifyLinks) + + // Invalid email -> rejected + rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code) + }) +} + +func TestAccount_Email_ProvisionedPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost) + require.Nil(t, err) + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}} + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailer = mailer + defer s.closeDatabases() + auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")} + + // A provisioned user's first verified email becomes their primary (used by X-Email: yes; + // password reset stays blocked separately for provisioned users) + verifyEmailFor(t, s, mailer, auth, "prov@example.com") + account := getAccount(t, s, auth) + require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account)) + require.Equal(t, "prov@example.com", primaryAddr(account)) + + // Verify a second address and explicitly set it primary -> allowed, star moves + verifyEmailFor(t, s, mailer, auth, "prov2@example.com") + rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov2@example.com"}`, auth) + require.Equal(t, 200, rr.Code) + account = getAccount(t, s, auth) + require.Equal(t, "prov2@example.com", primaryAddr(account)) + }) +} + +func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + // Provision a user via config (AuthUsers), with email sending enabled + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + conf.AuthUsers = []*user.User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser}, + } + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailer = mailer + defer s.closeDatabases() + + // Give the provisioned user a verified primary email anyway + prov, err := s.userManager.User("prov") + require.Nil(t, err) + require.True(t, prov.Provisioned) + require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com")) + + // Reset request by username and by email -> uniform 200, but no email sent (can't reset) + require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code) + require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).Code) + require.Empty(t, mailer.resetLinks) + }) +} + +func TestAccount_PasswordReset_InvalidToken(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, _, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code) + }) +} + +func TestAccount_Email_AddDuplicateVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) + token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) + + // Adding the same already-verified address is a conflict + rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth) + require.Equal(t, 409, rr.Code) + require.Equal(t, 40907, toHTTPError(t, rr.Body.String()).Code) + }) +} diff --git a/server/server_account_test.go b/server/server_account_test.go index 9aea7d4d..3c4f1787 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -78,7 +78,8 @@ func TestAccount_Signup_LimitReached(t *testing.T) { s := newTestServer(t, conf) defer s.closeDatabases() - for i := 0; i < 3; i++ { + // Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests) + for i := 0; i < 6; i++ { rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) require.Equal(t, 200, rr.Code) } @@ -131,7 +132,8 @@ func TestAccount_Signup_Rate_Limit(t *testing.T) { conf.EnableSignup = true s := newTestServer(t, conf) - for i := 0; i < 3; i++ { + // Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests) + for i := 0; i < 6; i++ { rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) require.Equal(t, 200, rr.Code, "failed on iteration %d", i) } @@ -149,7 +151,7 @@ func TestAccount_Get_Anonymous(t *testing.T) { conf.VisitorAttachmentTotalSizeLimit = 5123 conf.AttachmentFileSizeLimit = 512 s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() rr := request(t, s, "GET", "/v1/account", "", nil) diff --git a/server/server_manager.go b/server/server_manager.go index 387ad2b8..f9334d7e 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -54,8 +54,8 @@ func (s *Server) execManager() { receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts() } var sentMailTotal, sentMailSuccess, sentMailFailure int64 - if s.smtpSender != nil { - sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts() + if s.mailer != nil { + sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.NotificationCounts() } // Users diff --git a/server/server_middleware.go b/server/server_middleware.go index b8e650c5..cc77b6df 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -105,7 +105,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.mailSender == nil || s.userManager == nil { + if s.mailer == nil || s.userManager == nil { return errHTTPNotFound } return next(w, r, v) diff --git a/server/server_payments.go b/server/server_payments.go index 0226df4f..76216bbe 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -237,10 +237,41 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil { return err } + // Offer email recovery: auto-send a verification link to the billing email (best-effort). + // Provisioned users can't reset their password, so recovery setup doesn't apply to them. + if sess.CustomerDetails != nil && !u.Provisioned { + s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email) + } http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) return nil } +// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's +// billing email, so they can use it for password recovery -- but only if they have no verified +// email yet and the billing email is not already the recovery email on another account. On a +// collision (or any other skip), the generic "no recovery email set" warning on the account page +// nudges the user to add one. This is best-effort: failures are logged, never surfaced. +func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) { + if s.mailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { + return + } + emails, err := s.userManager.Emails(userID) + if err != nil { + logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification") + return + } else if len(emails) > 0 { + return // User already has a verified email -- don't nag + } + if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil { + logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification") + return // Collision: skip + let the generic no-recovery-email warning nudge instead + } + logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email") + if err := s.enqueueEmailVerification(userID, billingEmail); err != nil { + logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification") + } +} + // handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates // a user's tier accordingly. This endpoint only works if there is an existing subscription. func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { diff --git a/server/server_payments_email_test.go b/server/server_payments_email_test.go new file mode 100644 index 00000000..42a7aa43 --- /dev/null +++ b/server/server_payments_email_test.go @@ -0,0 +1,114 @@ +//go:build !nopayments + +package server + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stripe/stripe-go/v74" + "heckel.io/ntfy/v2/user" +) + +// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given +// billing email on the session's CustomerDetails. +func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI { + m := &testStripeAPI{} + m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{ + ClientReferenceID: u.ID, + Customer: &stripe.Customer{ID: "acct_5555"}, + Subscription: &stripe.Subscription{ID: "sub_1234"}, + CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail}, + }, nil) + m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{ + ID: "sub_1234", + Status: stripe.SubscriptionStatusActive, + CurrentPeriodEnd: 123456789, + Items: &stripe.SubscriptionItemList{ + Data: []*stripe.SubscriptionItem{ + {Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}}, + }, + }, + }, nil) + m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil) + return m +} + +func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) { + c := newTestConfigWithAuthFile(t, databaseURL) + c.StripeSecretKey = "secret key" + c.BaseURL = "https://ntfy.example.com" + c.SMTPSenderAddr = "localhost:25" + c.SMTPSenderFrom = "noreply@example.com" + s := newTestServer(t, c) + mailer := newCaptureMailer() + s.mailer = mailer + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + u, err := s.userManager.User("phil") + require.Nil(t, err) + return s, mailer, u +} + +func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + + // A verification link was auto-sent to the billing email; clicking it verifies + sets primary + link := mailer.verifyLinks["billing@example.com"] + require.NotEmpty(t, link) + token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) + + emails, err := s.userManager.Emails(u.ID) + require.Nil(t, err) + require.Equal(t, []string{"billing@example.com"}, emails) + primary, err := s.userManager.PrimaryEmail(u.ID) + require.Nil(t, err) + require.Equal(t, "billing@example.com", primary) + }) +} + +func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + // User already has a verified email -> no auto-send on checkout + require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com")) + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + require.Empty(t, mailer.verifyLinks) + }) +} + +func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + // The billing email is already the recovery email on another account -> skip + require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + alice, err := s.userManager.User("alice") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com")) + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + require.Empty(t, mailer.verifyLinks) + }) +} diff --git a/server/server_test.go b/server/server_test.go index bb4ddfba..5ebde045 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -264,6 +264,27 @@ func TestServer_StaticSites(t *testing.T) { }) } +func TestServer_WebApp_MagicLinkLandingPagesNoIndexHeaders(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) + + // Magic-link landing pages carry a one-time token in the path, so the response must not + // leak the token via the Referer header and must not be indexed + for _, path := range []string{"/account/email/verify/sometoken", "/account/password/reset/sometoken"} { + rr := request(t, s, "GET", path, "", nil) + require.Equal(t, 200, rr.Code, path) + require.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy"), path) + require.Equal(t, "noindex", rr.Header().Get("X-Robots-Tag"), path) + } + + // Ordinary web app routes do not set these headers + rr := request(t, s, "GET", "/", "", nil) + require.Equal(t, 200, rr.Code) + require.Empty(t, rr.Header().Get("Referrer-Policy")) + require.Empty(t, rr.Header().Get("X-Robots-Tag")) + }) +} + func TestServer_WebEnabled(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { conf := newTestConfig(t, databaseURL) @@ -740,7 +761,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { func TestServer_PublishInvalidTopic(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} response := request(t, s, "PUT", "/docs", "fail", nil) require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) }) @@ -1231,7 +1252,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { c := newTestConfigWithAuthFile(t, databaseURL) s := newTestServer(t, c) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} // Publish some messages, and check stats for i := 0; i < 3; i++ { @@ -1315,18 +1336,20 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) { } type testMailer struct { - count int - mu sync.Mutex + count int + lastTo string + mu sync.Mutex } -func (t *testMailer) Send(v *visitor, m *model.Message, to string) error { +func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error { t.mu.Lock() defer t.mu.Unlock() t.count++ + t.lastTo = to return nil } -func (t *testMailer) Counts() (total int64, success int64, failure int64) { +func (t *testMailer) NotificationCounts() (total int64, success int64, failure int64) { return 0, 0, 0 } @@ -1336,6 +1359,16 @@ func (t *testMailer) Count() int { return t.count } +func (t *testMailer) LastTo() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.lastTo +} + +func (t *testMailer) SendEmailVerification(to, link string) error { return nil } + +func (t *testMailer) SendPasswordReset(to, link string) error { return nil } + func TestServer_PublishTooManyRequests_Defaults(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) @@ -1461,7 +1494,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) { func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -1481,7 +1514,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { c := newTestConfig(t, databaseURL) c.VisitorEmailLimitReplenish = 500 * time.Millisecond s := newTestServer(t, c) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -1509,7 +1542,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { func TestServer_PublishDelayedEmail_Fail(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ "E-Mail": "test@example.com", "Delay": "20 min", @@ -1546,7 +1579,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { func TestServer_PublishEmailAddressInvalid(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} addresses := []string{ "test@example.com, other@example.com", "invalidaddress", @@ -1572,7 +1605,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1602,7 +1635,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1628,17 +1661,97 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { }) } -func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) { +func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderVerify = true + s := newTestServer(t, conf) + mailer := &testMailer{} + s.mailer = mailer + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + u, err := s.userManager.User("phil") + require.Nil(t, err) + // Two verified emails; the primary is NOT the alphabetically-first one + require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com")) + require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com")) + + // "yes" must resolve to the primary email, not emails[0] (alphabetically first) + response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ + "Email": "yes", + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, "zzz@example.com", mailer.LastTo()) + }) +} + +func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + // smtp-sender-verify intentionally left false (the default) + s := newTestServer(t, conf) + mailer := &testMailer{} + s.mailer = mailer + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com")) + require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com")) + + // Even with smtp-sender-verify off, "yes" resolves to the user's primary verified address + response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ + "Email": "yes", + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, "zzz@example.com", mailer.LastTo()) + }) +} + +func TestServer_PublishEmailVerify_BoolValueAnonymousRejected(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} - // "yes" without smtp-sender-verify should fail with invalid address + // "yes" requires an authenticated user (it means "my primary"); anonymous is rejected response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ "Email": "yes", }) require.Equal(t, 400, response.Code) - require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code) + }) +} + +func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost) + require.Nil(t, err) + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}} + s := newTestServer(t, conf) + mailer := &testMailer{} + s.mailer = mailer + defer s.closeDatabases() + + prov, err := s.userManager.User("prov") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(prov.ID, "aaa@example.com")) + require.Nil(t, s.userManager.AddEmail(prov.ID, "zzz@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "zzz@example.com")) + + // A provisioned user's "yes" resolves to their chosen primary, not the alphabetically-first + response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ + "Email": "yes", + "Authorization": util.BasicAuth("prov", "provpass"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, "zzz@example.com", mailer.LastTo()) }) } @@ -1647,7 +1760,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() // Anonymous user should be rejected @@ -1664,7 +1777,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1682,7 +1795,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) { func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.mailer = &testMailer{} // Without smtp-sender-verify, any email address should work (backwards compatible) response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ @@ -1706,11 +1819,11 @@ func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) { // Create a user without a tier require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - // Verify email request should NOT return 401 - response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{ + // Starting email verification should NOT return 401 + response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{ "Authorization": util.BasicAuth("ben", "ben"), }) - // The request will fail (SMTP not available), but it must NOT be a 401 + // The request may fail (SMTP not available), but it must NOT be a 401 require.NotEqual(t, 401, response.Code) }) } @@ -1731,7 +1844,7 @@ func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) // Should be rejected with 401 since email sending is disabled - response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{ + response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{ "Authorization": util.BasicAuth("ben", "ben"), }) require.Equal(t, 401, response.Code) @@ -2139,7 +2252,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) { t.Parallel() mailer := &testMailer{} s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = mailer + s.mailer = mailer body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 200, response.Code) diff --git a/server/types.go b/server/types.go index 1f69d3de..5b254c4d 100644 --- a/server/types.go +++ b/server/types.go @@ -185,6 +185,7 @@ type apiAccessResetRequest struct { type apiAccountCreateRequest struct { Username string `json:"username"` Password string `json:"password"` + Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent } type apiAccountPasswordChangeRequest struct { @@ -226,13 +227,29 @@ type apiAccountPhoneNumberAddRequest struct { Code string `json:"code"` // Only set when adding a phone number } -type apiAccountEmailVerifyRequest struct { +// apiAccountEmailRequest carries an email address for the add/delete/set-primary/resend +// endpoints (all of which identify an email by address in the JSON body). +type apiAccountEmailRequest struct { Email string `json:"email"` } -type apiAccountEmailAddRequest struct { - Email string `json:"email"` - Code string `json:"code"` +// apiAccountEmailVerifyRequest carries the raw magic-link token submitted (unauthenticated) +// from the verification landing page. +type apiAccountEmailVerifyRequest struct { + Token string `json:"token"` +} + +// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint. +// The identifier is a username or a primary email address. +type apiAccountPasswordResetRequest struct { + Identifier string `json:"identifier"` +} + +// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm +// endpoint, submitted from the set-new-password landing page. +type apiAccountPasswordResetConfirmRequest struct { + Token string `json:"token"` + Password string `json:"password"` } type apiAccountTier struct { @@ -271,6 +288,15 @@ type apiAccountReservation struct { Everyone string `json:"everyone"` } +// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account. +// Verified addresses have pending=false; exactly one verified address may be primary (the +// recovery email). Pending addresses are awaiting a magic-link click and are never primary. +type apiAccountEmailInfo struct { + Address string `json:"address"` + Primary bool `json:"primary,omitempty"` + Pending bool `json:"pending,omitempty"` +} + type apiAccountBilling struct { Customer bool `json:"customer"` Subscription bool `json:"subscription"` @@ -291,7 +317,7 @@ type apiAccountResponse struct { Reservations []*apiAccountReservation `json:"reservations,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` PhoneNumbers []string `json:"phone_numbers,omitempty"` - Emails []string `json:"emails,omitempty"` + Emails []*apiAccountEmailInfo `json:"emails,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"` @@ -304,21 +330,21 @@ type apiAccountReservationRequest struct { } 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"` - EnableEmails bool `json:"enable_emails"` - EnableEmailVerify bool `json:"enable_email_verify"` - EnableReservations bool `json:"enable_reservations"` - EnableWebPush bool `json:"enable_web_push"` - BillingContact string `json:"billing_contact"` - WebPushPublicKey string `json:"web_push_public_key"` - DisallowedTopics []string `json:"disallowed_topics"` - ConfigHash string `json:"config_hash"` + 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"` + EnableEmails bool `json:"enable_emails"` + EnableResetPassword bool `json:"enable_reset_password"` + EnableReservations bool `json:"enable_reservations"` + EnableWebPush bool `json:"enable_web_push"` + BillingContact string `json:"billing_contact"` + WebPushPublicKey string `json:"web_push_public_key"` + DisallowedTopics []string `json:"disallowed_topics"` + ConfigHash string `json:"config_hash"` } type apiAccountBillingPrices struct { diff --git a/server/visitor.go b/server/visitor.go index 3d4622dd..2f07d273 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -66,7 +66,7 @@ type visitor struct { subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads - accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil + accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil firebase time.Time // Next allowed Firebase message seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) @@ -280,8 +280,9 @@ func (v *visitor) AuthFailed() { } } -// AccountCreationAllowed returns true if a new account can be created -func (v *visitor) AccountCreationAllowed() bool { +// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset +// request) is currently allowed for this visitor +func (v *visitor) AccountActionAllowed() bool { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) { @@ -290,8 +291,9 @@ func (v *visitor) AccountCreationAllowed() bool { return true } -// AccountCreated decreases the account limiter. This is to be called after an account was created. -func (v *visitor) AccountCreated() { +// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited +// account action (signup or password-reset request). +func (v *visitor) AccountActionPerformed() { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() if v.accountLimiter != nil { diff --git a/user/manager.go b/user/manager.go index 34d96403..ba2c77d3 100644 --- a/user/manager.go +++ b/user/manager.go @@ -2,6 +2,7 @@ package user import ( + "crypto/subtle" "database/sql" "encoding/json" "errors" @@ -40,6 +41,7 @@ const ( DefaultUserPasswordBcryptCost = 10 DefaultAccessCacheEnabled = false DefaultAccessCacheReloadInterval = 87 * time.Second + DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept ) var ( @@ -72,6 +74,9 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { if config.AccessCacheReloadInterval <= 0 { config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval } + if config.ExpiredMagicLinkReapInterval <= 0 { + config.ExpiredMagicLinkReapInterval = DefaultExpiredMagicLinkReapInterval + } manager := &Manager{ config: config, db: d, @@ -91,6 +96,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval) } go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval) + go manager.asyncExpiredMagicLinkReapLoop(manager.config.ExpiredMagicLinkReapInterval) return manager, nil } @@ -128,6 +134,25 @@ func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) { } } +// asyncExpiredMagicLinkReapLoop periodically deletes expired email-verification and +// password-reset links so the user_magic_link table does not accumulate dead rows. Expiry is +// already enforced on read, so this is housekeeping only; it replaces the old in-memory +// expireLoop that lived in mail.Sender. +func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-a.quit: + return + case <-ticker.C: + if err := a.deleteExpiredMagicLinks(); err != nil { + log.Tag(tag).Err(err).Warn("Reaping expired magic links failed") + } + } + } +} + // Authenticate checks username and password and returns a User if correct, and the user has not been // marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or // the password is correct or incorrect. @@ -494,6 +519,19 @@ func (a *Manager) UserByID(id string) (*User, error) { return a.readUser(rows) } +// UserByEmailOrUsername resolves an identifier to a single user, trying it first as a primary +// email address and then as a username. A verified, owned email takes precedence over a +// freely-chosen username, so a look-alike username cannot shadow the email's real owner. Returns +// ErrUserNotFound if neither matches. +func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) { + if userID, err := a.UserIDByPrimaryEmail(identifier); err == nil { + if u, err := a.UserByID(userID); err == nil { + return u, nil + } + } + return a.User(identifier) +} + // userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise func (a *Manager) userByToken(token string) (*User, error) { rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix()) @@ -630,7 +668,7 @@ func (a *Manager) maybeHashPassword(password string, hashed bool) (string, error } return password, nil } - return hashPassword(password, a.config.BcryptCost) + return HashPassword(password, a.config.BcryptCost) } // Authorize returns nil if the given user has access to the given topic using the desired @@ -639,6 +677,13 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { if user != nil && user.Role == RoleAdmin { return nil // Admin can do everything } + // A user always has full access to their own sync topic, which the apps use + // to sync subscriptions/settings across devices. Without this, an + // auth-default-access of "deny-all" locks the user out of their own sync + // topic (no ACL entry is created for it at user creation). See #733. + if user != nil && user.SyncTopic != "" && subtle.ConstantTimeCompare([]byte(topic), []byte(user.SyncTopic)) == 1 { + return nil + } username := Everyone if user != nil { username = user.Name @@ -1451,12 +1496,266 @@ func (a *Manager) AddEmail(userID, email string) error { return nil } -// RemoveEmail deletes a verified email address from the user with the given user ID +// RemoveEmail deletes a verified email address from the user with the given user ID. +// Removing the primary email leaves the account with no primary -- there is deliberately +// no auto-promotion of another verified address; the user is nudged to pick a new one. func (a *Manager) RemoveEmail(userID, email string) error { _, err := a.db.Exec(a.queries.deleteEmail, userID, email) return err } +// PrimaryEmail returns the user's primary (recovery) email address, or an empty string if +// the user has not designated one. +func (a *Manager) PrimaryEmail(userID string) (string, error) { + var email sql.NullString + err := a.db.ReadOnly().QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } else if err != nil { + return "", err + } + return email.String, nil +} + +// UserIDByPrimaryEmail returns the ID of the (at most one) account for which the given address +// is the primary email. Returns ErrUserNotFound if no account claims it as primary. Used by the +// password-reset request flow to resolve an email identifier to a single account. +func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) { + var userID string + err := a.db.ReadOnly().QueryRow(a.queries.selectUserIDByPrimary, email).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrUserNotFound + } else if err != nil { + return "", err + } + return userID, nil +} + +// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with +// an outstanding email-verification magic link. +func (a *Manager) PendingEmails(userID string) ([]string, error) { + rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID) + if err != nil { + return nil, err + } + defer rows.Close() + emails := make([]string, 0) + for rows.Next() { + var email string + if err := rows.Scan(&email); err != nil { + return nil, err + } + emails = append(emails, email) + } + if err := rows.Err(); err != nil { + return nil, err + } + return emails, nil +} + +// SetPrimaryEmail marks a verified email address as the user's primary (recovery) email, +// clearing any previous primary in the same transaction. Returns ErrEmailNotFound if the +// address is not verified on the account, or ErrEmailPrimaryElsewhere if it is already the +// primary email on another account (enforced by the global partial unique index). +func (a *Manager) SetPrimaryEmail(userID, email string) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { + if _, err := tx.Exec(a.queries.updateEmailClearPrimary, userID); err != nil { + return err + } + res, err := tx.Exec(a.queries.updateEmailSetPrimary, userID, email) + if err != nil { + if isUniqueConstraintError(err) { + return ErrEmailPrimaryElsewhere + } + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrEmailNotFound // Address not verified on this account + } + return nil + }) +} + +// AddMagicLink generates a fresh magic-link token of the given kind, stores it (hashed, replacing +// any existing link in the same scope), and returns the RAW token for use in the emailed link. +// Only the hash is persisted; the raw token is never stored. email is the address being verified +// for email_verify, and "" for password_reset. +// +// The scope replaced is, for email_verify, the (user_id, email) pair (one pending verification per +// address); for password_reset, the user_id (one active reset per account). The replace-delete and +// the insert run in one transaction so a re-request atomically supersedes the old token. +func (a *Manager) AddMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) { + token := generateLinkToken() + now := time.Now() + m := &MagicLink{ + TokenHash: hashToken(token), + Kind: kind, + UserID: userID, + Email: email, + Expires: now.Add(ttl).Unix(), + Created: now.Unix(), + } + err := db.ExecTx(a.db, func(tx *sql.Tx) error { + switch m.Kind { + case MagicLinkKindEmailVerify: + if _, err := tx.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), m.UserID, m.Email); err != nil { + return err + } + case MagicLinkKindPasswordReset: + if _, err := tx.Exec(a.queries.deleteMagicLinkResetPassword, string(MagicLinkKindPasswordReset), m.UserID); err != nil { + return err + } + default: + return ErrInvalidArgument + } + if _, err := tx.Exec(a.queries.insertMagicLink, m.TokenHash, string(m.Kind), m.UserID, nullString(m.Email), m.Expires, m.Created); err != nil { + return err + } + return nil + }) + if err != nil { + return "", err + } + return token, nil +} + +// MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash. +func (a *Manager) MagicLinkByToken(rawToken string) (*MagicLink, error) { + return a.MagicLinkByHash(hashToken(rawToken)) +} + +// MagicLinkByHash looks up a magic link by the hex SHA-256 of its raw token, returning +// ErrMagicLinkNotFound if none exists. Callers must assert the returned Kind matches the flow +// they serve and check Expires themselves. +func (a *Manager) MagicLinkByHash(tokenHash string) (*MagicLink, error) { + var m MagicLink + var kind string + var email sql.NullString + err := a.db.ReadOnly().QueryRow(a.queries.selectMagicLinkByHash, tokenHash).Scan(&m.TokenHash, &kind, &m.UserID, &email, &m.Expires, &m.Created) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrMagicLinkNotFound + } else if err != nil { + return nil, err + } + m.Kind = MagicLinkKind(kind) + m.Email = email.String + return &m, nil +} + +// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume). +// Used to enforce single use after a reset is performed (email verification deletes the row +// inside VerifyEmail's transaction). +func (a *Manager) DeleteMagicLinkByToken(rawToken string) error { + _, err := a.db.Exec(a.queries.deleteMagicLinkByHash, hashToken(rawToken)) + return err +} + +// DeleteEmailVerification removes any pending email verification for (userID, email). Used when +// an unverified (pending) address is cancelled/deleted from the account. +func (a *Manager) DeleteEmailVerification(userID, email string) error { + _, err := a.db.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), userID, email) + return err +} + +// VerifyEmail consumes an email-verification magic link, identified by its raw token: after +// validating the token (kind + expiry), it deletes the link, adds the address to the user's +// verified emails, and -- if the user has no primary email yet and the address is not already +// primary on another account -- promotes the new address to primary. All mutations run in one +// transaction. A primary collision simply leaves the address verified but non-primary. Provisioned +// users never get a primary (the recovery email is meaningless for them -- they can't reset). +// Returns the consumed link. +func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { + tokenHash := hashToken(rawToken) + m, err := a.MagicLinkByHash(tokenHash) + if err != nil { + return nil, err + } + if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires { + return nil, ErrMagicLinkNotFound + } + err = db.ExecTx(a.db, func(tx *sql.Tx) error { + // Single use: delete the link, then add the (idempotent) verified address + if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil { + return err + } + if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil { + return err + } + // Promote to primary only if the user has none yet and the address is globally free. + // We check with SELECTs rather than catching a unique violation, because Postgres aborts + // the whole transaction on any constraint error (which would undo the verified-email add). + var primary sql.NullString + err := tx.QueryRow(a.queries.selectPrimaryEmail, m.UserID).Scan(&primary) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if primary.String != "" { + return nil // User already has a primary -- leave it + } + // If the address is already another account's primary, leave it a verified secondary here + var ownerUserID string + if err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&ownerUserID); err == nil { + return nil // Address is primary elsewhere -> not promoted + } else if !errors.Is(err, sql.ErrNoRows) { + return err // Real query error + } + // Address is globally free -> promote it to this user's primary + if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return m, nil +} + +// ResetPassword consumes a password-reset magic link, identified by its raw token: after +// validating the token (kind + expiry), it sets the user's password and deletes the link in one +// transaction. Existing access tokens are intentionally left valid (only the password changes). +// Returns ErrMagicLinkNotFound if the token is invalid, expired, or not a reset token. +func (a *Manager) ResetPassword(rawToken, newPassword string) error { + m, err := a.MagicLinkByHash(hashToken(rawToken)) + if err != nil { + return err + } + if m.Kind != MagicLinkKindPasswordReset || time.Now().Unix() > m.Expires { + return ErrMagicLinkNotFound + } + u, err := a.UserByID(m.UserID) + if err != nil { + return err + } + if u.Provisioned { + return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset + } + hash, err := HashPassword(newPassword, a.config.BcryptCost) + if err != nil { + return err + } + return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := a.changePasswordHashTx(tx, u.Name, hash); err != nil { + return err + } + if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, m.TokenHash); err != nil { + return err + } + return nil + }) +} + +// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced +// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop. +func (a *Manager) deleteExpiredMagicLinks() error { + _, err := a.db.Exec(a.queries.deleteExpiredMagicLinks, time.Now().Unix()) + return err +} + func (a *Manager) readEmail(rows *sql.Rows) (string, error) { var email string if !rows.Next() { diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 0395baae..a9e12d50 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -217,9 +217,23 @@ const ( postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` // Email queries - postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email` - postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)` - postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2` + postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email` + postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)` + postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING` + postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2` + postgresSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = $1 AND is_primary` + postgresSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = $1 AND is_primary` + postgresUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = TRUE WHERE user_id = $1 AND email = $2` + postgresUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = FALSE WHERE user_id = $1 AND is_primary` + + // Magic link queries (email verification + password reset) + postgresInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES ($1, $2, $3, $4, $5, $6)` + postgresSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = $1` + postgresDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = $1` + postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2 AND email = $3` + postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2` + postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = $1 AND user_id = $2 ORDER BY email` + postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1` // Billing queries postgresUpdateBillingQuery = ` @@ -306,7 +320,19 @@ var postgresQueries = queries{ deletePhoneNumber: postgresDeletePhoneNumberQuery, selectEmails: postgresSelectEmailsQuery, insertEmail: postgresInsertEmailQuery, + insertEmailIgnore: postgresInsertEmailIgnoreQuery, deleteEmail: postgresDeleteEmailQuery, + selectPrimaryEmail: postgresSelectPrimaryEmailQuery, + selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery, + updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery, + updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery, + insertMagicLink: postgresInsertMagicLinkQuery, + selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery, + deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery, + deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery, + deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery, + selectPendingEmails: postgresSelectPendingEmailsQuery, + deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery, updateBilling: postgresUpdateBillingQuery, } diff --git a/user/manager_postgres_schema.go b/user/manager_postgres_schema.go index ba8502f2..a855dc66 100644 --- a/user/manager_postgres_schema.go +++ b/user/manager_postgres_schema.go @@ -75,8 +75,21 @@ const ( CREATE TABLE IF NOT EXISTS user_email ( user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, email TEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (user_id, email) ); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT, + expires BIGINT NOT NULL, + created BIGINT NOT NULL, + PRIMARY KEY (token_hash) + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE TABLE IF NOT EXISTS schema_version ( store TEXT PRIMARY KEY, version INT NOT NULL @@ -89,7 +102,7 @@ const ( // Schema table management queries for Postgres const ( - postgresCurrentSchemaVersion = 7 + postgresCurrentSchemaVersion = 8 postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'` postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` ) @@ -102,11 +115,30 @@ const ( PRIMARY KEY (user_id, email) ); ` + + // 7 -> 8: primary (recovery) email + magic-link table for verification/reset. + // No backfill -- existing verified emails stay non-primary. + postgresMigrate7To8UpdateQueries = ` + ALTER TABLE user_email ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT FALSE; + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT, + expires BIGINT NOT NULL, + created BIGINT NOT NULL, + PRIMARY KEY (token_hash) + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); + ` postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'` ) var postgresMigrations = map[int]func(db *sql.DB) error{ 6: postgresMigrateFrom6, + 7: postgresMigrateFrom7, } func setupPostgres(db *sql.DB) error { @@ -141,6 +173,16 @@ func postgresMigrateFrom6(db *sql.DB) error { return nil } +func postgresMigrateFrom7(db *sql.DB) error { + if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil { + return err + } + if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil { + return err + } + return nil +} + func setupNewPostgres(db *sql.DB) error { if _, err := db.Exec(postgresCreateTablesQueries); err != nil { return err diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 62652ce3..a2a46e18 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -214,9 +214,23 @@ const ( sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` // Email queries - sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email` - sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` - sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` + sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email` + sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` + sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING` + sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` + sqliteSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = ? AND is_primary = 1` + sqliteSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1` + sqliteUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = 1 WHERE user_id = ? AND email = ?` + sqliteUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = 0 WHERE user_id = ? AND is_primary = 1` + + // Magic link queries (email verification + password reset) + sqliteInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES (?, ?, ?, ?, ?, ?)` + sqliteSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = ?` + sqliteDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = ?` + sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ? AND email = ?` + sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ?` + sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = ? AND user_id = ? ORDER BY email` + sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?` // Billing queries sqliteUpdateBillingQuery = ` @@ -302,7 +316,19 @@ var sqliteQueries = queries{ deletePhoneNumber: sqliteDeletePhoneNumberQuery, selectEmails: sqliteSelectEmailsQuery, insertEmail: sqliteInsertEmailQuery, + insertEmailIgnore: sqliteInsertEmailIgnoreQuery, deleteEmail: sqliteDeleteEmailQuery, + selectPrimaryEmail: sqliteSelectPrimaryEmailQuery, + selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery, + updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery, + updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery, + insertMagicLink: sqliteInsertMagicLinkQuery, + selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery, + deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery, + deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery, + deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery, + selectPendingEmails: sqliteSelectPendingEmailsQuery, + deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery, updateBilling: sqliteUpdateBillingQuery, } diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go index 6ee24f8c..5b389627 100644 --- a/user/manager_sqlite_schema.go +++ b/user/manager_sqlite_schema.go @@ -88,9 +88,23 @@ const ( CREATE TABLE IF NOT EXISTS user_email ( user_id TEXT NOT NULL, email TEXT NOT NULL, + is_primary INT NOT NULL DEFAULT (0), PRIMARY KEY (user_id, email), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + expires INT NOT NULL, + created INT NOT NULL, + PRIMARY KEY (token_hash), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL @@ -107,7 +121,7 @@ const ( // Schema version table management for SQLite const ( - sqliteCurrentSchemaVersion = 7 + sqliteCurrentSchemaVersion = 8 sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1` sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` @@ -236,6 +250,26 @@ const ( ); ` + // 7 -> 8: primary (recovery) email + magic-link table for verification/reset. + // No backfill -- existing verified emails stay non-primary, so the ALTER cannot + // conflict and no old notification address becomes a recovery channel. + sqliteMigrate7To8UpdateQueries = ` + ALTER TABLE user_email ADD COLUMN is_primary INT NOT NULL DEFAULT (0); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + expires INT NOT NULL, + created INT NOT NULL, + PRIMARY KEY (token_hash), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); + ` + // 5 -> 6 sqliteMigrate5To6UpdateQueries = ` PRAGMA foreign_keys=off; @@ -339,6 +373,7 @@ var ( 4: sqliteMigrateFrom4, 5: sqliteMigrateFrom5, 6: sqliteMigrateFrom6, + 7: sqliteMigrateFrom7, } ) @@ -493,3 +528,16 @@ func sqliteMigrateFrom6(sqlDB *sql.DB) error { return nil }) } + +func sqliteMigrateFrom7(sqlDB *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 7 to 8") + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil { + return err + } + return nil + }) +} diff --git a/user/manager_test.go b/user/manager_test.go index 6d13929e..13497a3b 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -2,6 +2,7 @@ package user import ( "database/sql" + "errors" "fmt" "net/netip" "path/filepath" @@ -129,6 +130,13 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) { require.Nil(t, a.Authorize(ben, "announcements", PermissionRead)) require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite)) + // User has full access to their own sync topic, even under deny-all, + // but not to another user's sync topic (#733) + require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionRead)) + require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionWrite)) + // User john should have // "deny" to mytopic_deny*, // "ro" to mytopic_ro*, @@ -2827,3 +2835,387 @@ func TestStoreOtherAccessCount(t *testing.T) { require.Equal(t, 2, count) // ben's owner entry + everyone entry }) } + +// addVerifyLink stores an email-verification magic link and returns the raw token so the test +// can "click" it via VerifyEmail. +func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string { + raw, err := a.AddMagicLink(MagicLinkKindEmailVerify, userID, email, ttl) + require.Nil(t, err) + return raw +} + +func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + + // Before verifying: pending, not yet verified, no primary + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Verify: the first verified email auto-becomes primary + m, err := a.VerifyEmail(raw) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + + emails, err = a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, emails) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "phil@example.com", primary) + pending, err = a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // Reset-by-email lookup resolves to the account + userID, err := a.UserIDByPrimaryEmail("phil@example.com") + require.Nil(t, err) + require.Equal(t, phil.ID, userID) + }) +} + +func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw1) + require.Nil(t, err) + + raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw2) + require.Nil(t, err) + + // Both verified, but primary is still the first + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"first@example.com", "second@example.com"}, emails) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "first@example.com", primary) + }) +} + +func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + + // phil verifies shared@ first -> becomes his primary + _, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour)) + require.Nil(t, err) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + + // ben verifies the same address -> allowed as secondary, but NOT his primary + _, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour)) + require.Nil(t, err) + emails, err := a.Emails(ben.ID) + require.Nil(t, err) + require.Equal(t, []string{"shared@example.com"}, emails) + primary, err = a.PrimaryEmail(ben.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Explicitly promoting ben's copy to primary collides with phil's + require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere) + // ...and phil keeps his primary (the failed promotion rolled back ben's clear) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + }) +} + +func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound) + }) +} + +func TestUser_MagicLink_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute) + _, err = a.VerifyEmail(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Nothing got verified + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + }) +} + +func TestUser_MagicLink_SingleUse(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw) + require.Nil(t, err) + // Second click: token already consumed + _, err = a.VerifyEmail(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + + // Only one pending row remains; the old token no longer works + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + _, err = a.MagicLinkByToken(raw1) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + m, err := a.MagicLinkByToken(raw2) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + }) +} + +func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + + m, err := a.MagicLinkByToken(raw) + require.Nil(t, err) + require.Equal(t, MagicLinkKindPasswordReset, m.Kind) + require.Equal(t, phil.ID, m.UserID) + require.Equal(t, "", m.Email) // reset rows carry no email + + // Reset rows do not appear as pending emails + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // New request replaces the old token + raw2, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.MagicLinkByToken(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Single use: deleting consumes it + require.Nil(t, a.DeleteMagicLinkByToken(raw2)) + _, err = a.MagicLinkByToken(raw2) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_Reaper(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour) + valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour) + + require.Nil(t, a.deleteExpiredMagicLinks()) + + _, err = a.MagicLinkByToken(expired) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + m, err := a.MagicLinkByToken(valid) + require.Nil(t, err) + require.Equal(t, "valid@example.com", m.Email) + }) +} + +// TestUser_MagicLink_ReaperLoop proves the background reap goroutine actually runs on its +// configured interval: an expired link inserted into a manager with a tiny reap interval is +// deleted without anyone calling deleteExpiredMagicLinks directly. Mirrors the loop-coverage +// pattern of TestAccessCacheReloadInterval_PicksUpExternalWrite. +func TestUser_MagicLink_ReaperLoop(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + BcryptCost: bcrypt.MinCost, + ExpiredMagicLinkReapInterval: 25 * time.Millisecond, + }) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour) + valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour) + + // The background loop (not a direct call) must reap the expired link within a few intervals + require.Eventually(t, func() bool { + _, err := a.MagicLinkByToken(expired) + return errors.Is(err, ErrMagicLinkNotFound) + }, 2*time.Second, 10*time.Millisecond, "reaper loop never deleted the expired magic link") + + // The unexpired link must survive + m, err := a.MagicLinkByToken(valid) + require.Nil(t, err) + require.Equal(t, "valid@example.com", m.Email) + }) +} + +func TestUser_MagicLink_ResetPassword(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + + // Old password works before reset + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + + require.Nil(t, a.ResetPassword(raw, "newpass")) + + // New password works, old does not + _, err = a.Authenticate("phil", "newpass") + require.Nil(t, err) + _, err = a.Authenticate("phil", "oldpass") + require.ErrorIs(t, err, ErrUnauthenticated) + + // Token is single-use + require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + // An email-verification token must not be usable for password reset... + verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour) + require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound) + + // ...and a reset token must not be usable for email verification + resetToken, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.VerifyEmail(resetToken) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Old password unchanged + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + +func TestUser_MagicLink_VerifyEmail_ProvisionedGetsPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + ProvisionEnabled: true, + Users: []*User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + }) + prov, err := a.User("prov") + require.Nil(t, err) + + // A provisioned user's first verified email becomes their primary, just like a regular user + // (the primary is also the X-Email: yes target; password reset stays blocked separately). + _, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour)) + require.Nil(t, err) + + emails, err := a.Emails(prov.ID) + require.Nil(t, err) + require.Equal(t, []string{"prov@example.com"}, emails) + primary, err := a.PrimaryEmail(prov.ID) + require.Nil(t, err) + require.Equal(t, "prov@example.com", primary) + }) +} + +func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + // Provisioned users come from the config file (ProvisionEnabled), not AddUser + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + ProvisionEnabled: true, + Users: []*User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + }) + prov, err := a.User("prov") + require.Nil(t, err) + require.True(t, prov.Provisioned) + + // A reset token can be created, but consuming it must be rejected for a provisioned user + // (their password comes from the config file, like change-pass). + raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange) + }) +} + +func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound) + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + +func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + _, err := a.UserIDByPrimaryEmail("ghost@example.com") + require.ErrorIs(t, err, ErrUserNotFound) + }) +} diff --git a/user/types.go b/user/types.go index c400e48f..e198b273 100644 --- a/user/types.go +++ b/user/types.go @@ -73,6 +73,27 @@ type TokenUpdate struct { LastOrigin netip.Addr } +// MagicLinkKind discriminates the two link-token flows stored in the user_magic_link table. +type MagicLinkKind string + +// Magic link kinds +const ( + MagicLinkKindEmailVerify MagicLinkKind = "email_verify" + MagicLinkKindPasswordReset MagicLinkKind = "password_reset" +) + +// MagicLink is a pending, single-use link token -- either an email verification or a +// password reset, distinguished by Kind. The raw token travels in the emailed link; +// only its TokenHash (hex SHA-256) is persisted. +type MagicLink struct { + TokenHash string + Kind MagicLinkKind + UserID string + Email string // Address being verified for email_verify; empty (NULL) for password_reset + Expires int64 + Created int64 +} + // Prefs represents a user's configuration settings type Prefs struct { Language *string `json:"language,omitempty"` @@ -245,18 +266,19 @@ const ( // Config holds the configuration for the user Manager type Config struct { - Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite) - DatabaseURL string // Database connection string (PostgreSQL) - StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only) - DefaultAccess Permission // Default permission if no ACL matches - ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands - Users []*User // Predefined users to create on startup - Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant) - Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) - QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database - BcryptCost int // Cost of generated passwords; lowering makes testing faster - AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only) - AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI + Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite) + DatabaseURL string // Database connection string (PostgreSQL) + StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only) + DefaultAccess Permission // Default permission if no ACL matches + ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands + Users []*User // Predefined users to create on startup + Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant) + Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) + QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database + BcryptCost int // Cost of generated passwords; lowering makes testing faster + AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only) + AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI + ExpiredMagicLinkReapInterval time.Duration // Interval for sweeping expired email-verify/password-reset links } // Error constants used by the package @@ -275,6 +297,8 @@ var ( ErrPhoneNumberExists = errors.New("phone number already exists") ErrEmailNotFound = errors.New("email not found") ErrEmailExists = errors.New("email already exists") + ErrEmailPrimaryElsewhere = errors.New("email is the primary email on another account") + ErrMagicLinkNotFound = errors.New("magic link not found") ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user") ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") ) @@ -350,9 +374,23 @@ type queries struct { deletePhoneNumber string // Email queries - selectEmails string - insertEmail string - deleteEmail string + selectEmails string + insertEmail string + insertEmailIgnore string // Idempotent insert (ON CONFLICT DO NOTHING) used inside VerifyEmail + deleteEmail string + selectPrimaryEmail string + selectUserIDByPrimary string + updateEmailSetPrimary string + updateEmailClearPrimary string + + // Magic link queries (email verification + password reset) + insertMagicLink string + selectMagicLinkByHash string + deleteMagicLinkByHash string + deleteMagicLinkEmailVerify string // Delete pending email_verify rows for (user_id, email) + deleteMagicLinkResetPassword string // Delete the active password_reset row for user_id + selectPendingEmails string // Pending (unverified) email addresses for a user + deleteExpiredMagicLinks string // Billing queries updateBilling string diff --git a/user/util.go b/user/util.go index 16f6cc09..50d0c012 100644 --- a/user/util.go +++ b/user/util.go @@ -1,7 +1,9 @@ package user import ( + "crypto/sha256" "database/sql" + "encoding/hex" "regexp" "strings" @@ -9,6 +11,11 @@ import ( "heckel.io/ntfy/v2/util" ) +// linkTokenLength is the length of a raw magic-link token. At 48 base62 characters +// it carries ~285 bits of entropy, well above the ~256-bit target, so the tokens +// need no brute-force cap -- just expiry and single-use. +const linkTokenLength = 48 + var ( allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*) allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' @@ -67,12 +74,23 @@ 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) +// generateLinkToken returns a fresh high-entropy raw token for a magic link +// (email verification or password reset). The raw token is carried in the emailed +// link; only its hashToken digest is persisted. +func generateLinkToken() string { + return util.RandomString(linkTokenLength) } -func hashPassword(password string, cost int) (string, error) { +// hashToken returns the hex-encoded SHA-256 digest of a raw magic-link token. +// Tokens are stored hashed so a database read cannot yield working links; a high-entropy +// token makes a fast (unsalted) hash sufficient, unlike a password. +func hashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// HashPassword hashes the given password using bcrypt with the given cost +func HashPassword(password string, cost int) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) if err != nil { return "", err diff --git a/user/util_test.go b/user/util_test.go index 97c4bc4a..2fa542ab 100644 --- a/user/util_test.go +++ b/user/util_test.go @@ -176,7 +176,7 @@ func TestHashPassword(t *testing.T) { password := "test-password-123" // Hash the password - hash, err := HashPassword(password) + hash, err := HashPassword(password, DefaultUserPasswordBcryptCost) require.Nil(t, err) require.NotEmpty(t, hash) @@ -187,12 +187,12 @@ func TestHashPassword(t *testing.T) { require.True(t, strings.HasPrefix(hash, "$2a$")) // Hash the same password again - should produce different hash - hash2, err := HashPassword(password) + hash2, err := HashPassword(password, DefaultUserPasswordBcryptCost) require.Nil(t, err) require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)") // Empty password should still work - emptyHash, err := HashPassword("") + emptyHash, err := HashPassword("", DefaultUserPasswordBcryptCost) require.Nil(t, err) require.NotEmpty(t, emptyHash) require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost)) @@ -202,15 +202,15 @@ func TestHashPassword_WithCost(t *testing.T) { password := "test-password" // Test with different costs - hash4, err := hashPassword(password, 4) + hash4, err := HashPassword(password, 4) require.Nil(t, err) require.True(t, strings.HasPrefix(hash4, "$2a$04$")) - hash10, err := hashPassword(password, 10) + hash10, err := HashPassword(password, 10) require.Nil(t, err) require.True(t, strings.HasPrefix(hash10, "$2a$10$")) - hash12, err := hashPassword(password, 12) + hash12, err := HashPassword(password, 12) require.Nil(t, err) require.True(t, strings.HasPrefix(hash12, "$2a$12$")) diff --git a/util/util.go b/util/util.go index be349691..1c924e2b 100644 --- a/util/util.go +++ b/util/util.go @@ -2,20 +2,19 @@ package util import ( "bytes" + crand "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math" - "math/rand" "net/netip" "os" "regexp" "slices" "strconv" "strings" - "sync" "time" "unicode/utf8" @@ -30,8 +29,6 @@ const ( ) var ( - random = rand.New(rand.NewSource(time.Now().UnixNano())) - randomMutex = sync.Mutex{} sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`) errInvalidPriority = errors.New("invalid priority") noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`) @@ -144,14 +141,33 @@ func RandomLowerStringPrefix(prefix string, length int) string { return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset) } +// randomStringPrefixWithCharset builds a random string from charset using crypto/rand. +// We use rejection sampling (dropping the few highest byte values that would skew the +// distribution) so every character is uniformly distributed -- important because these +// strings back security tokens (access tokens, magic-link tokens, IDs), not just labels. func randomStringPrefixWithCharset(prefix string, length int, charset string) string { - randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?! - defer randomMutex.Unlock() - b := make([]byte, length-len(prefix)) - for i := range b { - b[i] = charset[random.Intn(len(charset))] + n := length - len(prefix) + if n <= 0 { + return prefix[:length] } - return prefix + string(b) + result := make([]byte, n) + limit := 256 - (256 % len(charset)) // reject byte values >= limit to avoid modulo bias + buf := make([]byte, n) + for i := 0; i < n; { + if _, err := crand.Read(buf); err != nil { + panic("crypto/rand failed: " + err.Error()) // Should never happen on a sane system + } + for _, c := range buf { + if i >= n { + break + } + if int(c) < limit { + result[i] = charset[int(c)%len(charset)] + i++ + } + } + } + return prefix + string(result) } // ValidRandomString returns true if the given string matches the format created by RandomString diff --git a/util/util_test.go b/util/util_test.go index ae855147..8de18b66 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -25,6 +25,30 @@ func TestRandomString(t *testing.T) { require.NotEqual(t, s1, s2) } +// TestRandomString_CSPRNG guards the crypto/rand-backed generator: every character must come +// from the expected charset (rejection sampling correctness) and a large batch must be unique +// (no clock-seeded PRNG collapsing to a predictable stream). +func TestRandomString_CSPRNG(t *testing.T) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + seen := make(map[string]bool) + charCounts := make(map[rune]int) + for i := 0; i < 5000; i++ { + s := RandomString(48) + require.Equal(t, 48, len(s)) + require.False(t, seen[s], "duplicate random string generated") + seen[s] = true + for _, c := range s { + require.Contains(t, charset, string(c)) + charCounts[c]++ + } + } + // Every charset character should appear at least once across 5000*48 draws; a heavily + // biased or broken generator would leave gaps. + for _, c := range charset { + require.Greater(t, charCounts[c], 0, "character %q never appeared", string(c)) + } +} + func TestFileExists(t *testing.T) { filename := filepath.Join(t.TempDir(), "somefile.txt") require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600)) diff --git a/web/package-lock.json b/web/package-lock.json index 8d925f8c..f8d2d489 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,14 +12,14 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", - "@mui/material": "*", + "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^3.0.5", - "react": "*", - "react-dom": "*", + "react": "latest", + "react-dom": "latest", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-remark": "^2.1.0", @@ -2778,9 +2778,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2801,9 +2801,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", "cpu": [ "arm" ], @@ -2815,9 +2815,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", "cpu": [ "arm64" ], @@ -2829,9 +2829,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", "cpu": [ "arm64" ], @@ -2843,9 +2843,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", "cpu": [ "x64" ], @@ -2857,9 +2857,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", "cpu": [ "arm64" ], @@ -2871,9 +2871,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", "cpu": [ "x64" ], @@ -2885,13 +2885,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2899,13 +2902,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2913,13 +2919,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2927,13 +2936,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2941,13 +2953,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2955,13 +2970,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2969,13 +2987,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2983,13 +3004,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2997,13 +3021,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3011,13 +3038,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3025,13 +3055,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3039,13 +3072,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3053,13 +3089,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3067,9 +3106,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", "cpu": [ "x64" ], @@ -3081,9 +3120,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", "cpu": [ "arm64" ], @@ -3095,9 +3134,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", "cpu": [ "arm64" ], @@ -3109,9 +3148,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", "cpu": [ "ia32" ], @@ -3123,9 +3162,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", "cpu": [ "x64" ], @@ -3137,9 +3176,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", "cpu": [ "x64" ], @@ -3219,9 +3258,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -3254,9 +3293,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "peer": true, "dependencies": { @@ -3321,9 +3360,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -3614,9 +3653,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", - "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3708,9 +3747,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3832,9 +3871,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -4269,9 +4308,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.362", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.362.tgz", - "integrity": "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==", + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", "dev": true, "license": "ISC" }, @@ -4380,9 +4419,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", + "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", "dev": true, "license": "MIT", "dependencies": { @@ -4690,9 +4729,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5245,18 +5284,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -5536,9 +5578,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5911,6 +5953,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6300,10 +6358,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6791,9 +6859,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, "license": "MIT", "engines": { @@ -7109,9 +7177,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7286,24 +7354,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/react-i18next": { @@ -7341,9 +7409,9 @@ } }, "node_modules/react-is": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, "node_modules/react-refresh": { @@ -7516,9 +7584,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7637,13 +7705,13 @@ } }, "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -7653,31 +7721,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" } }, @@ -7859,15 +7927,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -8076,19 +8144,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8098,16 +8167,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -8325,9 +8394,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -8467,18 +8536,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -8751,9 +8820,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { @@ -8975,9 +9044,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", - "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 2e06cc64..a6e942a0 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -3,12 +3,20 @@ "common_save": "Save", "common_add": "Add", "common_back": "Back", + "common_close": "Close", "common_copy_to_clipboard": "Copy to clipboard", "common_refresh": "Refresh", + "email_verify_progress_title": "Verifying your email...", + "email_verify_success_title": "Email verified", + "email_verify_success_description": "Your email address has been verified and added to your account.", + "email_verify_error_title": "Verification failed", + "email_verify_error_description": "This verification link is invalid or has expired. You can request a new one from your account settings.", + "email_verify_button_account": "Go to account", "version_update_available_title": "New version available", "version_update_available_description": "The ntfy server has been updated. Please refresh the page.", "signup_title": "Create a ntfy account", "signup_form_username": "Username", + "signup_form_email": "Email (optional, for account recovery)", "signup_form_password": "Password", "signup_form_confirm_password": "Confirm password", "signup_form_button_submit": "Sign up", @@ -20,6 +28,23 @@ "login_title": "Sign in to your ntfy account", "login_form_button_submit": "Sign in", "login_link_signup": "Sign up", + "login_link_forgot_password": "Forgot password", + "reset_password_request_title": "Reset password", + "reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed.", + "reset_password_request_primary_required": "This only works if you already added a primary email address and verified it.", + "reset_password_request_identifier_label": "Username or email", + "reset_password_request_button_submit": "Send reset link", + "reset_password_sent_title": "Check your inbox", + "reset_password_sent_description": "If an account exists, a link to reset your password has been emailed.", + "reset_password_back_to_login": "Back to sign-in", + "reset_password_disabled": "Password reset is disabled", + "reset_password_title": "Set a new password", + "reset_password_form_password": "New password", + "reset_password_form_confirm": "Confirm new password", + "reset_password_form_button_submit": "Set password", + "reset_password_form_error_invalid": "This reset link is invalid or has expired. Please request a new one.", + "reset_password_success_title": "Password changed", + "reset_password_success_description": "Your password has been changed. You can now sign in with your new password.", "login_disabled": "Login is disabled", "action_bar_show_menu": "Show menu", "action_bar_logo_alt": "ntfy logo", @@ -216,18 +241,26 @@ "account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_basics_phone_numbers_dialog_channel_call": "Call", "account_basics_emails_title": "Email addresses", - "account_basics_emails_description": "For email notifications", - "account_basics_emails_no_emails_yet": "No verified emails yet", + "account_basics_emails_description": "For email notifications and password reset", + "account_basics_emails_no_emails_yet": "No emails yet", "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", + "account_basics_emails_chip_actions_primary": "Primary address, used as your default email address. Click for actions.", + "account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.", + "account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.", + "account_basics_emails_unverified": "unverified", + "account_basics_emails_set_primary": "Set as primary email", + "account_basics_emails_delete": "Remove address", + "account_basics_emails_resend": "Resend verification email", + "account_basics_emails_resent": "Verification email sent, check your inbox", + "account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account", + "account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.", + "account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.", "account_basics_emails_dialog_title": "Add email address", - "account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.", + "account_basics_emails_dialog_description": "Enter an email address to add it to your account. A verification link will be sent to confirm it is yours.", "account_basics_emails_dialog_email_label": "Email address", "account_basics_emails_dialog_email_placeholder": "e.g. user@example.com", - "account_basics_emails_dialog_verify_button": "Add email", - "account_basics_emails_dialog_code_label": "Verification code", - "account_basics_emails_dialog_code_placeholder": "e.g. 123456", - "account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired", - "account_basics_emails_dialog_check_verification_button": "Confirm", + "account_basics_emails_dialog_verify_button": "Send verification link", + "account_basics_emails_dialog_check_inbox": "Check your inbox and click the verification link to confirm this email address. It will appear as unverified until you do.", "account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", @@ -236,9 +269,10 @@ "account_basics_tier_title": "Account type", "account_basics_tier_description": "Your account's power level", "account_basics_tier_admin": "Admin", - "account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)", - "account_basics_tier_admin_suffix_no_tier": "(no tier)", + "account_basics_tier_admin_suffix_with_tier": "with {{tier}} tier", + "account_basics_tier_admin_suffix_no_tier": "no tier", "account_basics_tier_basic": "Basic", + "account_basics_tier_provisioned": "Provisioned", "account_basics_tier_free": "Free", "account_basics_tier_interval_monthly": "monthly", "account_basics_tier_interval_yearly": "annually", @@ -286,7 +320,6 @@ "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls", "account_upgrade_dialog_tier_features_no_calls": "No phone calls", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_price_per_month": "month", "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.", "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.", @@ -418,7 +451,6 @@ "error_boundary_button_copy_stack_trace": "Copy stack trace", "error_boundary_button_reload_ntfy": "Reload ntfy", "error_boundary_stack_trace": "Stack trace", - "error_boundary_gathering_info": "Gather more info …", "error_boundary_unsupported_indexeddb_title": "Private browsing not supported", "error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.

While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it in this GitHub issue, or talk to us on Discord or Matrix.", "web_push_subscription_expiring_title": "Notifications will be paused", diff --git a/web/public/static/langs/te.json b/web/public/static/langs/te.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/te.json @@ -0,0 +1 @@ +{} diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 4fadb8c5..10facafd 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -4,6 +4,10 @@ import { accountBillingSubscriptionUrl, accountEmailUrl, accountEmailVerifyUrl, + accountEmailPrimaryUrl, + accountEmailResendUrl, + accountPasswordResetRequestUrl, + accountPasswordResetUrl, accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, @@ -65,11 +69,12 @@ class AccountApi { }); } - async create(username, password) { + async create(username, password, email) { const url = accountUrl(config.base_url); const body = JSON.stringify({ username, password, + email: email || "", }); console.log(`[AccountApi] Creating user account ${url}`); await fetchOrThrow(url, { @@ -342,9 +347,11 @@ class AccountApi { }); } - async verifyEmail(email) { - const url = accountEmailVerifyUrl(config.base_url); - console.log(`[AccountApi] Sending email verification ${url}`); + // startEmailVerification begins adding an email: the server stores a pending verification and + // emails a magic link. The address is not verified until the link is clicked. + async startEmailVerification(email) { + const url = accountEmailUrl(config.base_url); + console.log(`[AccountApi] Starting email verification ${url}`); await fetchOrThrow(url, { method: "PUT", headers: withBearerAuth({}, session.token()), @@ -354,15 +361,67 @@ class AccountApi { }); } - async addEmail(email, code) { - const url = accountEmailUrl(config.base_url); - console.log(`[AccountApi] Adding email with verification code ${url}`); + // verifyEmailToken performs verification from the magic-link landing page. It is unauthenticated: + // the token identifies the account, so this works even when clicked from a logged-out browser. + async verifyEmailToken(token) { + const url = accountEmailVerifyUrl(config.base_url); + console.log(`[AccountApi] Verifying email token ${url}`); await fetchOrThrow(url, { - method: "PUT", + method: "POST", + body: JSON.stringify({ + token, + }), + }); + } + + // resendEmailVerification re-sends the magic link for a pending (unverified) address. + async resendEmailVerification(email) { + const url = accountEmailResendUrl(config.base_url); + console.log(`[AccountApi] Resending email verification ${url}`); + await fetchOrThrow(url, { + method: "POST", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ email, - code, + }), + }); + } + + // setPrimaryEmail marks an already-verified address as the primary (recovery) email. + async setPrimaryEmail(email) { + const url = accountEmailPrimaryUrl(config.base_url); + console.log(`[AccountApi] Setting primary email ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + email, + }), + }); + } + + // requestPasswordReset starts the (unauthenticated) reset flow. The identifier is a username or + // primary email. The server always responds uniformly, regardless of whether an account matched. + async requestPasswordReset(identifier) { + const url = accountPasswordResetRequestUrl(config.base_url); + console.log(`[AccountApi] Requesting password reset ${url}`); + await fetchOrThrow(url, { + method: "POST", + body: JSON.stringify({ + identifier, + }), + }); + } + + // resetPassword performs the (unauthenticated) reset from the set-new-password landing page. + async resetPassword(token, password) { + const url = accountPasswordResetUrl(config.base_url); + console.log(`[AccountApi] Resetting password ${url}`); + await fetchOrThrow(url, { + method: "POST", + body: JSON.stringify({ + token, + password, }), }); } diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 4214ad84..34c86f8d 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -31,11 +31,11 @@ export class TopicReservedError extends Error { } } -export class AccountCreateLimitReachedError extends Error { - static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation +export class AccountActionLimitReachedError extends Error { + static CODE = 42906; // errHTTPTooManyRequestsLimitAccountActions constructor() { - super("Account creation limit reached"); + super("Account action limit reached"); } } @@ -51,7 +51,15 @@ export class EmailVerificationCodeInvalidError extends Error { static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid constructor() { - super("Email verification code invalid or expired"); + super("Email verification link invalid or expired"); + } +} + +export class EmailPrimaryElsewhereError extends Error { + static CODE = 40908; // errHTTPConflictEmailPrimaryElsewhere + + constructor() { + super("Email address is the recovery email on another account"); } } @@ -67,12 +75,14 @@ export const throwAppError = async (response) => { throw new UserExistsError(); } else if (error.code === TopicReservedError.CODE) { throw new TopicReservedError(); - } else if (error.code === AccountCreateLimitReachedError.CODE) { - throw new AccountCreateLimitReachedError(); + } else if (error.code === AccountActionLimitReachedError.CODE) { + throw new AccountActionLimitReachedError(); } else if (error.code === IncorrectPasswordError.CODE) { throw new IncorrectPasswordError(); } else if (error.code === EmailVerificationCodeInvalidError.CODE) { throw new EmailVerificationCodeInvalidError(); + } else if (error.code === EmailPrimaryElsewhereError.CODE) { + throw new EmailPrimaryElsewhereError(); } else if (error?.error) { throw new Error(`Error ${error.code}: ${error.error}`); } diff --git a/web/src/app/utils.js b/web/src/app/utils.js index db38801b..13f50ffd 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -35,6 +35,10 @@ export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`; export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`; +export const accountEmailPrimaryUrl = (baseUrl) => `${baseUrl}/v1/account/email/primary`; +export const accountEmailResendUrl = (baseUrl) => `${baseUrl}/v1/account/email/resend`; +export const accountPasswordResetRequestUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset/request`; +export const accountPasswordResetUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset`; export const validUrl = (url) => url.match(/^https?:\/\/.+/); diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 42402d41..24631dc9 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useContext, useState } from "react"; import { Alert, + Box, CardActions, CardContent, Chip, @@ -31,13 +32,19 @@ import { DialogContent, TextField, IconButton, + Menu, MenuItem, + ListItemIcon, + ListItemText, DialogContentText, useTheme, } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import { Trans, useTranslation } from "react-i18next"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import StarIcon from "@mui/icons-material/Star"; +import StarBorderIcon from "@mui/icons-material/StarBorder"; +import RefreshIcon from "@mui/icons-material/Refresh"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import CelebrationIcon from "@mui/icons-material/Celebration"; import CloseIcon from "@mui/icons-material/Close"; @@ -52,7 +59,7 @@ import UpgradeDialog from "./UpgradeDialog"; import { AccountContext } from "./App"; import DialogFooter from "./DialogFooter"; import { Paragraph } from "./styles"; -import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors"; +import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { ProChip } from "./SubscriptionPopup"; import session from "../app/Session"; @@ -263,22 +270,23 @@ const AccountType = () => { } }; + // The account type is a base label ("Admin", "Basic", "Free", or the tier name) plus an optional + // qualifier chip (admin tier status, or the billing interval). let accountType; + let qualifierChip; if (account.role === Role.ADMIN) { - const tierSuffix = account.tier - ? t("account_basics_tier_admin_suffix_with_tier", { - tier: account.tier.name, - }) + accountType = t("account_basics_tier_admin"); + qualifierChip = account.tier + ? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name }) : t("account_basics_tier_admin_suffix_no_tier"); - accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; } else if (!account.tier) { accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic"); } else { accountType = account.tier.name; if (account.billing?.interval === SubscriptionInterval.MONTH) { - accountType += ` (${t("account_basics_tier_interval_monthly")})`; + qualifierChip = t("account_basics_tier_interval_monthly"); } else if (account.billing?.interval === SubscriptionInterval.YEAR) { - accountType += ` (${t("account_basics_tier_interval_yearly")})`; + qualifierChip = t("account_basics_tier_interval_yearly"); } } @@ -290,6 +298,8 @@ const AccountType = () => { >
{accountType} + {qualifierChip && } + {account.provisioned && } {account.billing?.paid_until && !account.billing?.cancel_at && ( { const { account } = useContext(AccountContext); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); - const [snackOpen, setSnackOpen] = useState(false); + const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message + const [menuAnchor, setMenuAnchor] = useState(null); // Chip element the actions menu is anchored to + const [menuEmail, setMenuEmail] = useState(null); // The email the open menu acts on const labelId = "prefVerifiedEmails"; + const openMenu = (ev, email) => { + setMenuAnchor(ev.currentTarget); + setMenuEmail(email); + }; + const closeMenu = () => { + setMenuAnchor(null); + setMenuEmail(null); + }; + const runMenuAction = (fn) => { + closeMenu(); + fn(menuEmail.address); + }; + const handleDialogOpen = () => { setDialogKey((prev) => prev + 1); setDialogOpen(true); @@ -373,21 +398,37 @@ const Emails = () => { const handleCopy = (email) => { copyToClipboard(email); - setSnackOpen(true); + setSnack(t("account_basics_emails_copied_to_clipboard")); }; - const handleDelete = async (email) => { + // runEmailAction wraps an account API call with the shared error handling (redirect on + // unauthorized, surface a message otherwise). On success it refetches the account so the email + // list reflects the change immediately, rather than waiting for the async sync event. + const runEmailAction = async (fn, errorMessage) => { try { - await accountApi.deleteEmail(email); + await fn(); + await accountApi.sync(); } catch (e) { - console.log(`[Account] Error deleting email`, e); + console.log(`[Account] Email action failed`, e); if (e instanceof UnauthorizedError) { await session.resetAndRedirect(routes.login); + } else if (e instanceof EmailPrimaryElsewhereError) { + setSnack(t("account_basics_emails_primary_elsewhere")); + } else { + setSnack(errorMessage ?? e.message); } } }; - if (!config.enable_email_verify) { + const handleDelete = (email) => runEmailAction(() => accountApi.deleteEmail(email)); + const handleSetPrimary = (email) => runEmailAction(() => accountApi.setPrimaryEmail(email)); + const handleResend = (email) => + runEmailAction(async () => { + await accountApi.resendEmailVerification(email); + setSnack(t("account_basics_emails_resent")); + }); + + if (!config.enable_emails) { return null; } @@ -407,35 +448,105 @@ const Emails = () => { ); } + const emails = account?.emails ?? []; + // Verified addresses, primary always first + const verifiedEmails = emails.filter((e) => !e.pending).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0)); + const pendingEmails = emails.filter((e) => e.pending); + const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? ""; + // Recovery nudges (skipped for provisioned users -- they can't reset, and the Add-email dialog + // explains the limitation): prompt for a first email when there are none, or for a primary when + // there are emails but none is primary. + const recoveryRelevant = config.enable_reset_password && !account?.provisioned; + const hasNoEmails = verifiedEmails.length === 0 && pendingEmails.length === 0; + const showNoEmailWarning = recoveryRelevant && hasNoEmails; + const showNoPrimaryWarning = recoveryRelevant && !hasNoEmails && primaryEmail === ""; + return ( - +
- {account?.emails?.map((email) => ( - - {email} - - } - variant="outlined" - onClick={() => handleCopy(email)} - onDelete={() => handleDelete(email)} - /> - ))} - {!account?.emails && {t("account_basics_emails_no_emails_yet")}} - - - + + {verifiedEmails.map((email) => ( + : undefined} + label={ + + {email.address} + + } + variant="outlined" + onClick={(ev) => openMenu(ev, email)} + onDelete={() => handleDelete(email.address)} + sx={email.primary ? { "& .MuiChip-icon": { color: "#fbc02d" } } : undefined} + /> + ))} + {pendingEmails.map((email) => ( + + + {email.address} ({t("account_basics_emails_unverified")}) + + + } + variant="outlined" + onClick={(ev) => openMenu(ev, email)} + onDelete={() => handleDelete(email.address)} + sx={{ opacity: 0.7 }} + /> + ))} + {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} + + + + + {showNoEmailWarning && ( + + {t("account_basics_emails_no_recovery_warning")} + + )} + {showNoPrimaryWarning && ( + + {t("account_basics_emails_no_primary_warning")} + + )}
+ + runMenuAction(handleCopy)}> + + + + {t("common_copy_to_clipboard")} + + {menuEmail && !menuEmail.pending && !menuEmail.primary && ( + runMenuAction(handleSetPrimary)}> + + + + {t("account_basics_emails_set_primary")} + + )} + {menuEmail && menuEmail.pending && ( + runMenuAction(handleResend)}> + + + + {t("account_basics_emails_resend")} + + )} + runMenuAction(handleDelete)}> + + + + {t("account_basics_emails_delete")} + + - setSnackOpen(false)} - message={t("account_basics_emails_copied_to_clipboard")} - /> + setSnack("")} message={snack} />
); @@ -446,18 +557,21 @@ const AddEmailDialog = (props) => { const { t } = useTranslation(); const [error, setError] = useState(""); const [email, setEmail] = useState(""); - const [code, setCode] = useState(""); const [sending, setSending] = useState(false); - const [verificationCodeSent, setVerificationCodeSent] = useState(false); + const [sent, setSent] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const verifyEmail = async () => { + // handleSubmit starts verification: the server emails a magic link. The pending address shows + // up in the account list as "(unverified)" once the account refreshes. + const handleSubmit = async () => { try { setSending(true); - await accountApi.verifyEmail(email); - setVerificationCodeSent(true); + setError(""); // Clear any error from a previous attempt + await accountApi.startEmailVerification(email); + await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately + setSent(true); } catch (e) { - console.log(`[Account] Error sending email verification`, e); + console.log(`[Account] Error starting email verification`, e); if (e instanceof UnauthorizedError) { await session.resetAndRedirect(routes.login); } else { @@ -468,81 +582,41 @@ const AddEmailDialog = (props) => { } }; - const checkVerifyEmail = async () => { - try { - setSending(true); - await accountApi.addEmail(email, code); - props.onClose(); - } catch (e) { - console.log(`[Account] Error confirming email verification`, e); - if (e instanceof UnauthorizedError) { - await session.resetAndRedirect(routes.login); - } else if (e instanceof EmailVerificationCodeInvalidError) { - setError(t("account_basics_emails_dialog_code_invalid")); - } else { - setError(e.message); - } - } finally { - setSending(false); - } - }; - - const handleDialogSubmit = async () => { - if (!verificationCodeSent) { - await verifyEmail(); - } else { - await checkVerifyEmail(); - } - }; - - const handleCancel = () => { - if (verificationCodeSent) { - setVerificationCodeSent(false); - setCode(""); - } else { - props.onClose(); - } - }; - return ( - + {t("account_basics_emails_dialog_title")} - {t("account_basics_emails_dialog_description")} - {!verificationCodeSent && ( - setEmail(ev.target.value)} - fullWidth - variant="standard" - /> - )} - {verificationCodeSent && ( - setCode(ev.target.value)} - fullWidth - inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} - variant="standard" - /> + {sent ? ( + {t("account_basics_emails_dialog_check_inbox")} + ) : ( + <> + {t("account_basics_emails_dialog_description")} + setEmail(ev.target.value)} + fullWidth + variant="standard" + /> + )} - - + {sent ? ( + + ) : ( + <> + + + + )} ); @@ -604,22 +678,24 @@ const PhoneNumbers = () => { return (
- {account?.phone_numbers?.map((phoneNumber) => ( - - {phoneNumber} - - } - variant="outlined" - onClick={() => handleCopy(phoneNumber)} - onDelete={() => handleDelete(phoneNumber)} - /> - ))} - {!account?.phone_numbers && {t("account_basics_phone_numbers_no_phone_numbers_yet")}} - - - + + {account?.phone_numbers?.map((phoneNumber) => ( + + {phoneNumber} + + } + variant="outlined" + onClick={() => handleCopy(phoneNumber)} + onDelete={() => handleDelete(phoneNumber)} + /> + ))} + {!account?.phone_numbers && {t("account_basics_phone_numbers_no_phone_numbers_yet")}} + + + +
diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 575304d4..d386fedd 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -20,6 +20,9 @@ import Messaging from "./Messaging"; import Login from "./Login"; import Signup from "./Signup"; import Account from "./Account"; +import EmailVerify from "./EmailVerify"; +import PasswordReset from "./PasswordReset"; +import PasswordResetRequest from "./PasswordResetRequest"; import initI18n from "../app/i18n"; // Translations! import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; @@ -63,6 +66,9 @@ const App = () => { } /> } /> + } /> + } /> + } /> }> } /> } /> diff --git a/web/src/components/EmailVerify.jsx b/web/src/components/EmailVerify.jsx new file mode 100644 index 00000000..8ada6886 --- /dev/null +++ b/web/src/components/EmailVerify.jsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Typography, Button, Box, CircularProgress } from "@mui/material"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import { useParams, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import routes from "./routes"; + +// Verification states for the email-verify landing page +const STATUS_VERIFYING = "verifying"; +const STATUS_SUCCESS = "success"; +const STATUS_ERROR = "error"; + +// EmailVerify is the magic-link landing page for email verification. It performs the verification +// via a POST (the GET that loads this page has no side effects, so link prefetchers / scanners +// cannot consume the single-use token). The raw token is stripped from the URL on load to keep +// it out of browser history and Referer headers. +const EmailVerify = () => { + const { t } = useTranslation(); + const { token } = useParams(); + const navigate = useNavigate(); + const [status, setStatus] = useState(STATUS_VERIFYING); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) { + return; // Guard against double-invoke (e.g. React StrictMode) consuming the token twice + } + ran.current = true; + // Strip the token from the URL immediately (keep it out of history / Referer) + window.history.replaceState(null, "", routes.account); + (async () => { + try { + await accountApi.verifyEmailToken(token); + setStatus(STATUS_SUCCESS); + } catch (e) { + console.log(`[EmailVerify] Verification failed`, e); + setStatus(STATUS_ERROR); + } + })(); + }, [token]); + + return ( + + {status === STATUS_VERIFYING && ( + + + {t("email_verify_progress_title")} + + )} + {status === STATUS_SUCCESS && ( + <> + + + {t("email_verify_success_title")} + + {t("email_verify_success_description")} + + + )} + {status === STATUS_ERROR && ( + <> + + + {t("email_verify_error_title")} + + {t("email_verify_error_description")} + + + )} + + ); +}; + +export default EmailVerify; diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index 5c1af249..ad14ebb9 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -100,7 +100,13 @@ const Login = () => { )} - {/* This is where the password reset link would go */} + {config.enable_reset_password && ( +
+ + {t("login_link_forgot_password")} + +
+ )} {config.enable_signup && (
diff --git a/web/src/components/PasswordReset.jsx b/web/src/components/PasswordReset.jsx new file mode 100644 index 00000000..68a86eed --- /dev/null +++ b/web/src/components/PasswordReset.jsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Typography, TextField, Button, Box } from "@mui/material"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import { useParams, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import routes from "./routes"; + +// PasswordReset is the magic-link landing page for setting a new password. There is no +// pre-validation: the form renders directly and an invalid/expired token surfaces as an error on +// submit. The raw token is stripped from the URL on load (kept out of history / Referer). +const PasswordReset = () => { + const { t } = useTranslation(); + const { token: tokenParam } = useParams(); + const navigate = useNavigate(); + const token = useRef(tokenParam); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [sending, setSending] = useState(false); + const [done, setDone] = useState(false); + + useEffect(() => { + // Strip the token from the URL bar immediately (keep it out of history / Referer) + window.history.replaceState(null, "", routes.login); + }, []); + + const handleSubmit = async (event) => { + event.preventDefault(); + try { + setSending(true); + setError(""); + await accountApi.resetPassword(token.current, password); + setDone(true); + } catch (e) { + console.log(`[PasswordReset] Reset failed`, e); + setError(t("reset_password_form_error_invalid")); + } finally { + setSending(false); + } + }; + + if (done) { + return ( + + + + {t("reset_password_success_title")} + + {t("reset_password_success_description")} + + + ); + } + + return ( + + {t("reset_password_title")} + + setPassword(ev.target.value.trim())} + autoComplete="new-password" + autoFocus + /> + setConfirm(ev.target.value.trim())} + autoComplete="new-password" + /> + + {error && ( + + + {error} + + )} + + + ); +}; + +export default PasswordReset; diff --git a/web/src/components/PasswordResetRequest.jsx b/web/src/components/PasswordResetRequest.jsx new file mode 100644 index 00000000..f268dd62 --- /dev/null +++ b/web/src/components/PasswordResetRequest.jsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import { useState } from "react"; +import { TextField, Button, Box, Typography } from "@mui/material"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import { NavLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import routes from "./routes"; + +// PasswordResetRequest is the standalone "request a password reset" page, reached from the login page. +// It collects a username/email and asks the server to email a reset link. The response is uniform, +// so the page always shows the same confirmation. Completing the reset happens on the separate +// PasswordReset landing page that the emailed link points to. +const PasswordResetRequest = () => { + const { t } = useTranslation(); + const [identifier, setIdentifier] = useState(""); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + try { + setSending(true); + await accountApi.requestPasswordReset(identifier); + } catch (e) { + console.log(`[PasswordResetRequest] Request failed`, e); + } finally { + setSending(false); + setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe) + } + }; + + if (!config.enable_reset_password) { + return ( + + {t("reset_password_disabled")} + + + {t("reset_password_back_to_login")} + + + + ); + } + + if (sent) { + return ( + + + + {t("reset_password_sent_title")} + + {t("reset_password_sent_description")} + + + {t("reset_password_back_to_login")} + + + + ); + } + + return ( + + {t("reset_password_request_title")} + + {t("reset_password_request_description")} + {t("reset_password_request_primary_required")} + setIdentifier(ev.target.value.trim())} + autoFocus + /> + + + {config.enable_login && ( + + + {t("reset_password_back_to_login")} + + + )} + + ); +}; + +export default PasswordResetRequest; diff --git a/web/src/components/Signup.jsx b/web/src/components/Signup.jsx index 7da54c49..8dcd4ea2 100644 --- a/web/src/components/Signup.jsx +++ b/web/src/components/Signup.jsx @@ -9,12 +9,13 @@ import accountApi from "../app/AccountApi"; import AvatarBox from "./AvatarBox"; import session from "../app/Session"; import routes from "./routes"; -import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors"; +import { AccountActionLimitReachedError, UserExistsError } from "../app/errors"; const Signup = () => { const { t } = useTranslation(); const [error, setError] = useState(""); const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [showPassword, setShowPassword] = useState(false); @@ -24,7 +25,7 @@ const Signup = () => { event.preventDefault(); const user = { username, password }; try { - await accountApi.create(user.username, user.password); + await accountApi.create(user.username, user.password, email); const token = await accountApi.login(user); console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); await session.store(user.username, token); @@ -33,7 +34,7 @@ const Signup = () => { console.log(`[Signup] Signup for user ${user.username} failed`, e); if (e instanceof UserExistsError) { setError(t("signup_error_username_taken", { username: e.username })); - } else if (e instanceof AccountCreateLimitReachedError) { + } else if (e instanceof AccountActionLimitReachedError) { setError(t("signup_error_creation_limit_reached")); } else { setError(e.message); @@ -64,6 +65,18 @@ const Signup = () => { onChange={(ev) => setUsername(ev.target.value.trim())} autoFocus /> + {config.enable_emails && ( + setEmail(ev.target.value.trim())} + /> + )} {