mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Merge branch 'main' of github.com:binwiederhier/ntfy into 1771-delete-clear-via-get
This commit is contained in:
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout ntfy code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
repository: binwiederhier/ntfy-docs.github.io
|
||||
path: build/ntfy-docs.github.io
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
+88
-3
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-3
@@ -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.
|
||||
|
||||
@@ -189,6 +189,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)
|
||||
- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)
|
||||
- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services
|
||||
- [Rubix Notify](https://wordpress.org/plugins/rubix-notify) - WordPress Integration with ntfy (PHP + React).
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
|
||||
+8
-6
@@ -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)
|
||||
|
||||
|
||||
+47
-4
@@ -1,7 +1,7 @@
|
||||
# Publishing
|
||||
Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)).
|
||||
Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
|
||||
something that's not easily guessable.
|
||||
something that's not easily guessable (see [picking a topic](#picking-a-topic) for a handy topic name generator).
|
||||
|
||||
Here's an example showing how to publish a simple message using a POST request:
|
||||
|
||||
@@ -308,6 +308,44 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
|
||||
<figcaption>Notification using a click action, a user action, with an external image attachment and forwarded via email</figcaption>
|
||||
</figure>
|
||||
|
||||
## Picking a topic
|
||||
Since there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Topic names may
|
||||
only contain letters, numbers, underscores and dashes (`[-_A-Za-z0-9]`), and may be up to 64 characters long.
|
||||
|
||||
Not sure what to pick? Type a name below and the generator will add a random, hard-to-guess suffix for you. Everything happens locally in your browser:
|
||||
|
||||
<div id="tg-widget" class="tg-generator">
|
||||
<div class="tg-header">
|
||||
<span class="tg-title">Topic name generator</span>
|
||||
<button type="button" id="tg-reroll" class="tg-reset" title="Generate a new random suffix">Regenerate suffix</button>
|
||||
</div>
|
||||
<div class="tg-body">
|
||||
<div class="tg-left">
|
||||
<div class="tg-field">
|
||||
<label for="tg-input">Type a topic name</label>
|
||||
<input type="text" id="tg-input" placeholder="e.g. backups, alerts, phil-home" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="tg-note">Spaces and characters other than letters, numbers, <code>-</code> and <code>_</code> are removed automatically as you type. Names are capped at 64 characters.</div>
|
||||
</div>
|
||||
<div class="tg-right">
|
||||
<div class="tg-output-row">
|
||||
<span class="tg-output-label">Your topic:</span>
|
||||
<div class="tg-output-line">
|
||||
<pre class="tg-output" id="tg-output-name"></pre>
|
||||
<button type="button" class="tg-btn-copy" data-copy="tg-output-name" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-output-row">
|
||||
<span class="tg-output-label">Your topic URL:</span>
|
||||
<div class="tg-output-line">
|
||||
<pre class="tg-output" id="tg-output-url">https://ntfy.sh/</pre>
|
||||
<button type="button" class="tg-btn-copy" data-copy="tg-output-url" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Message title
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
|
||||
@@ -3217,8 +3255,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
|
||||
@@ -3668,7 +3711,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) |
|
||||
|
||||
|
||||
@@ -1950,10 +1950,27 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
### 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
|
||||
* You can how clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
**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)
|
||||
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) 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
|
||||
|
||||
Vendored
+235
@@ -0,0 +1,235 @@
|
||||
/* Topic name generator (Publishing page) */
|
||||
/* Styled to mirror the config generator (header + left form / right output panels). */
|
||||
|
||||
.tg-generator {
|
||||
margin: 16px 0 24px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Header (matches .cg-modal-header) */
|
||||
.tg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.tg-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tg-reset {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tg-reset:hover {
|
||||
color: #333;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
/* Body: left (form) + right (output), matches .cg-modal-body */
|
||||
.tg-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tg-left {
|
||||
flex: 1;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tg-right {
|
||||
flex: 1;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* One output per block: label on its own line, then value field + copy button */
|
||||
.tg-output-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tg-output-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Form field (matches .cg-field) */
|
||||
.tg-field > label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tg-field input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tg-field input[type="text"]:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
|
||||
}
|
||||
|
||||
.tg-note {
|
||||
margin-top: 10px;
|
||||
font-size: 0.72rem;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tg-note code {
|
||||
font-size: 0.72rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.tg-output-label {
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Copy button (matches .cg-btn-copy) */
|
||||
.tg-btn-copy {
|
||||
background: none;
|
||||
color: #777;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tg-btn-copy:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Output block (matches .cg-output-wrap pre). Scoped under .tg-generator so the margin
|
||||
reset beats the theme's .md-typeset pre rule, which otherwise adds a stray top margin. */
|
||||
.tg-generator .tg-output {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 6px 9px;
|
||||
background: #f5f5f5;
|
||||
color: var(--md-default-fg-color);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--md-code-font-family, monospace);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
body[data-md-color-scheme="slate"] .tg-generator {
|
||||
background: #1e1e2e;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-header {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-reset {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-reset:hover {
|
||||
border-color: #888;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-left {
|
||||
border-right-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-field > label,
|
||||
body[data-md-color-scheme="slate"] .tg-output-label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-btn-copy {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-btn-copy:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-field input[type="text"] {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-note {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-output {
|
||||
background: #161620;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
/* Responsive: stack panels like the config generator does on mobile */
|
||||
@media (max-width: 700px) {
|
||||
.tg-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tg-left {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-left {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
}
|
||||
Vendored
+121
@@ -0,0 +1,121 @@
|
||||
// Topic name generator for the ntfy docs
|
||||
//
|
||||
// A tiny helper that lives on the "Publishing" page. The user types a memorable
|
||||
// prefix (e.g. "backups"), and the widget appends a random, hard-to-guess suffix
|
||||
// (e.g. "backups-x7Kp2mQ9"). The result is a valid, unguessable topic name.
|
||||
//
|
||||
// Topic names on the server must match ^[-_A-Za-z0-9]{1,64}$ (see server.go), so as
|
||||
// the user types we strip anything that isn't allowed (spaces, slashes, punctuation,
|
||||
// emoji, ...) live and cap the whole thing at 64 characters. The random suffix is
|
||||
// generated once on load and can be re-rolled with the "Regenerate suffix" button.
|
||||
(function () {
|
||||
// Allowed topic characters per the server regex ^[-_A-Za-z0-9]{1,64}$
|
||||
const ALLOWED = /[^-_A-Za-z0-9]/g;
|
||||
const MAX_LEN = 64;
|
||||
|
||||
// Suffix alphabet: full base62 (letters + digits). We deliberately keep look-alikes
|
||||
// (0/O, l/1) for maximum entropy -- this is a generated suffix, not something typed by
|
||||
// hand. Hyphen/underscore are excluded so the "-" separator stays visually clear.
|
||||
const SUFFIX_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const SUFFIX_LEN = 10;
|
||||
|
||||
// randomSuffix returns a cryptographically random string from SUFFIX_ALPHABET.
|
||||
// It uses rejection sampling to avoid the modulo bias that a plain `byte % 62` would
|
||||
// introduce (256 is not a multiple of 62), keeping every character equally likely.
|
||||
function randomSuffix() {
|
||||
const n = SUFFIX_ALPHABET.length;
|
||||
const limit = Math.floor(256 / n) * n; // largest multiple of n that fits in a byte
|
||||
const buf = new Uint8Array(1);
|
||||
let out = "";
|
||||
while (out.length < SUFFIX_LEN) {
|
||||
crypto.getRandomValues(buf);
|
||||
if (buf[0] < limit) {
|
||||
out += SUFFIX_ALPHABET[buf[0] % n];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// sanitize strips everything that isn't a valid topic character.
|
||||
function sanitize(value) {
|
||||
return value.replace(ALLOWED, "");
|
||||
}
|
||||
|
||||
function initTopicGenerator() {
|
||||
const root = document.getElementById("tg-widget");
|
||||
if (!root) return;
|
||||
|
||||
const input = root.querySelector("#tg-input");
|
||||
const outputName = root.querySelector("#tg-output-name");
|
||||
const outputUrl = root.querySelector("#tg-output-url");
|
||||
const reroll = root.querySelector("#tg-reroll");
|
||||
|
||||
let suffix = randomSuffix();
|
||||
|
||||
// update recomputes the live preview from the (sanitized) input + current suffix.
|
||||
function update() {
|
||||
// Sanitize in place so the user sees disallowed characters disappear as they type.
|
||||
const cleaned = sanitize(input.value);
|
||||
if (cleaned !== input.value) {
|
||||
const pos = input.selectionStart - (input.value.length - cleaned.length);
|
||||
// Reassigning .value and setSelectionRange make the browser scroll the field into
|
||||
// view (there is no preventScroll option for setSelectionRange), which jumps the
|
||||
// whole page. Capture the scroll position and restore it afterwards.
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
input.value = cleaned;
|
||||
// Best-effort caret restore so removing a bad char doesn't jump the cursor to the end.
|
||||
try { input.setSelectionRange(pos, pos); } catch { /* ignore */ }
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
}
|
||||
|
||||
// Compose "<prefix>-<suffix>", capped at the 64-char topic limit. With no prefix,
|
||||
// fall back to just the random suffix so the output is always a valid topic.
|
||||
let topic;
|
||||
if (cleaned === "") {
|
||||
topic = suffix;
|
||||
} else {
|
||||
const maxPrefix = MAX_LEN - suffix.length - 1; // room for "-" + suffix
|
||||
const prefix = cleaned.slice(0, Math.max(0, maxPrefix));
|
||||
topic = prefix === "" ? suffix : prefix + "-" + suffix;
|
||||
}
|
||||
|
||||
outputName.textContent = topic;
|
||||
outputUrl.textContent = "https://ntfy.sh/" + topic;
|
||||
}
|
||||
|
||||
input.addEventListener("input", update);
|
||||
reroll.addEventListener("click", function () {
|
||||
suffix = randomSuffix();
|
||||
update();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// Copy buttons: copy the target output and briefly swap the clipboard icon for a checkmark,
|
||||
// mirroring the config generator's copy button behavior.
|
||||
const copyIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>";
|
||||
const checkIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>";
|
||||
root.querySelectorAll(".tg-btn-copy").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const target = root.querySelector("#" + btn.dataset.copy);
|
||||
if (!target || !target.textContent) return;
|
||||
navigator.clipboard.writeText(target.textContent).then(function () {
|
||||
btn.innerHTML = checkIcon;
|
||||
btn.style.color = "var(--md-primary-fg-color)";
|
||||
setTimeout(function () {
|
||||
btn.innerHTML = copyIcon;
|
||||
btn.style.color = "";
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initTopicGenerator);
|
||||
} else {
|
||||
initTopicGenerator();
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package mail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+80
-94
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,11 @@ extra_javascript:
|
||||
- static/js/extra.js
|
||||
- static/js/bcrypt.js
|
||||
- static/js/config-generator.js
|
||||
- static/js/topic-generator.js
|
||||
extra_css:
|
||||
- static/css/extra.css
|
||||
- static/css/config-generator.css
|
||||
- static/css/topic-generator.css
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
|
||||
@@ -22,6 +22,7 @@ func TestParseURL_Success(t *testing.T) {
|
||||
require.Equal(t, "us-east-1", cfg.Region)
|
||||
require.Equal(t, "AKID", cfg.AccessKey)
|
||||
require.Equal(t, "SECRET", cfg.SecretKey)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "s3.us-east-1.amazonaws.com", cfg.Endpoint)
|
||||
require.False(t, cfg.PathStyle)
|
||||
}
|
||||
@@ -38,6 +39,7 @@ func TestParseURL_WithEndpoint(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "my-bucket", cfg.Bucket)
|
||||
require.Equal(t, "prefix", cfg.Prefix)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "s3.example.com", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
}
|
||||
@@ -45,10 +47,32 @@ func TestParseURL_WithEndpoint(t *testing.T) {
|
||||
func TestParseURL_EndpointHTTP(t *testing.T) {
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=http://localhost:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "http", cfg.Scheme)
|
||||
require.Equal(t, "localhost:9000", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointNoScheme(t *testing.T) {
|
||||
// A bare host:port endpoint (no scheme) must default to https for backward compatibility.
|
||||
// Without this, url.Parse treats the host as the scheme ("localhost:9000" -> scheme "localhost").
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=localhost:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "localhost:9000", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
require.Equal(t, "https://localhost:9000/my-bucket", cfg.BucketURL())
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointNoSchemeHostname(t *testing.T) {
|
||||
// A dotted hostname with a port and no scheme must also default to https
|
||||
// ("minio.example.com:9000" must not become scheme "minio.example.com").
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=minio.example.com:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "minio.example.com:9000", cfg.Endpoint)
|
||||
require.Equal(t, "https://minio.example.com:9000/my-bucket", cfg.BucketURL())
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointTrailingSlash(t *testing.T) {
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=https://s3.example.com/")
|
||||
require.Nil(t, err)
|
||||
@@ -111,6 +135,11 @@ func TestConfig_BucketURL_PathStyle(t *testing.T) {
|
||||
require.Equal(t, "https://s3.example.com/my-bucket", c.BucketURL())
|
||||
}
|
||||
|
||||
func TestConfig_BucketURL_PathStyle_EndpointHTTP(t *testing.T) {
|
||||
c := &Config{Scheme: "http", Endpoint: "localhost:9000", Bucket: "b", PathStyle: true}
|
||||
require.Equal(t, "http://localhost:9000/b", c.BucketURL())
|
||||
}
|
||||
|
||||
func TestConfig_BucketURL_VirtualHosted(t *testing.T) {
|
||||
c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false}
|
||||
require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.BucketURL())
|
||||
|
||||
+8
-3
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
// Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string.
|
||||
type Config struct {
|
||||
Scheme string // URL scheme, e.g. "https" or "http"
|
||||
Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com"
|
||||
PathStyle bool
|
||||
Bucket string
|
||||
@@ -24,10 +25,14 @@ type Config struct {
|
||||
|
||||
// BucketURL returns the base URL for bucket-level operations.
|
||||
func (c *Config) BucketURL() string {
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket)
|
||||
scheme := "https"
|
||||
if c.Scheme != "" {
|
||||
scheme = c.Scheme
|
||||
}
|
||||
return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint)
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("%s://%s/%s", scheme, c.Endpoint, c.Bucket)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s.%s", scheme, c.Bucket, c.Endpoint)
|
||||
}
|
||||
|
||||
// HostHeader returns the value for the Host header.
|
||||
|
||||
+10
-1
@@ -70,21 +70,30 @@ func ParseURL(s3URL string) (*Config, error) {
|
||||
return nil, fmt.Errorf("s3: region query parameter is required")
|
||||
}
|
||||
endpointParam := u.Query().Get("endpoint")
|
||||
var scheme string
|
||||
var endpoint string
|
||||
var pathStyle bool
|
||||
if endpointParam != "" {
|
||||
// Custom endpoint: strip scheme prefix to extract host[:port]
|
||||
// Custom endpoint: derive the scheme from the prefix and strip it to extract host[:port].
|
||||
// Default to https for backward compatibility, including bare "host:port" endpoints (no
|
||||
// scheme) -- url.Parse would otherwise misread the host before the port colon as the scheme.
|
||||
scheme = "https"
|
||||
if strings.HasPrefix(endpointParam, "http://") {
|
||||
scheme = "http"
|
||||
}
|
||||
ep := strings.TrimRight(endpointParam, "/")
|
||||
ep = strings.TrimPrefix(ep, "https://")
|
||||
ep = strings.TrimPrefix(ep, "http://")
|
||||
endpoint = ep
|
||||
pathStyle = true
|
||||
} else {
|
||||
scheme = "https"
|
||||
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region)
|
||||
pathStyle = false
|
||||
}
|
||||
disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2"))
|
||||
return &Config{
|
||||
Scheme: scheme,
|
||||
Endpoint: endpoint,
|
||||
PathStyle: pathStyle,
|
||||
Bucket: bucket,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+63
-42
@@ -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:<ip> or user:<user>
|
||||
firebaseClient *firebaseClient
|
||||
@@ -94,8 +93,13 @@ var (
|
||||
deletePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/delete$`)
|
||||
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"
|
||||
@@ -117,6 +121,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"
|
||||
@@ -177,16 +185,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 != "" {
|
||||
@@ -291,8 +298,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,
|
||||
@@ -444,9 +450,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()
|
||||
}
|
||||
@@ -543,7 +546,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 {
|
||||
@@ -552,9 +555,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)
|
||||
@@ -612,12 +615,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 {
|
||||
@@ -644,9 +655,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && deletePathRegex.MatchString(r.URL.Path) {
|
||||
} else if (r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path)) || (r.Method == http.MethodGet && deletePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
|
||||
} else if (r.Method == http.MethodGet || r.Method == http.MethodPut) && clearPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
|
||||
@@ -662,17 +671,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 {
|
||||
@@ -681,8 +703,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 {
|
||||
@@ -718,21 +739,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,7 +970,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 != "" {
|
||||
@@ -1111,7 +1132,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
|
||||
@@ -1211,7 +1232,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")
|
||||
|
||||
+273
-55
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+137
-24
@@ -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)
|
||||
|
||||
+46
-20
@@ -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 {
|
||||
|
||||
+7
-5
@@ -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 {
|
||||
|
||||
+301
-2
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-3
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
+53
-15
@@ -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
|
||||
|
||||
+22
-4
@@ -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
|
||||
|
||||
+6
-6
@@ -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$"))
|
||||
|
||||
|
||||
+26
-10
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Generated
+276
-207
@@ -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": {
|
||||
|
||||
@@ -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.<br/><br/>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 <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
|
||||
"web_push_subscription_expiring_title": "Notifications will be paused",
|
||||
|
||||
+345
-163
@@ -1,227 +1,409 @@
|
||||
{
|
||||
"action_bar_send_test_notification": "Teszt értesítés küldése",
|
||||
"action_bar_clear_notifications": "Összes értesítés törlése",
|
||||
"alert_not_supported_description": "A böngésződ nem támogatja az értesítések fogadását",
|
||||
"action_bar_send_test_notification": "Tesztértesítés küldése",
|
||||
"action_bar_clear_notifications": "Az összes értesítés törlése",
|
||||
"alert_not_supported_description": "A böngésző nem támogatja az értesítéseket",
|
||||
"action_bar_settings": "Beállítások",
|
||||
"action_bar_unsubscribe": "Leiratkozás",
|
||||
"message_bar_type_message": "Írd ide az üzenetet",
|
||||
"message_bar_error_publishing": "Hiba történt az értesítés elküldése közben",
|
||||
"nav_button_all_notifications": "Összes értesítés",
|
||||
"message_bar_type_message": "Írj ide egy üzenetet",
|
||||
"message_bar_error_publishing": "Hiba az értesítés közzétételénél",
|
||||
"nav_button_all_notifications": "Minden értesítés",
|
||||
"nav_topics_title": "Feliratkozott témák",
|
||||
"alert_notification_permission_required_title": "Az értesítések le vannak tiltva",
|
||||
"alert_notification_permission_required_description": "Engedélyezd a böngésződnek, hogy asztali értesítéseket jelenítsen meg",
|
||||
"alert_notification_permission_required_description": "Engedélyezze a böngészőjében az asztali értesítések megjelenítését",
|
||||
"nav_button_settings": "Beállítások",
|
||||
"nav_button_documentation": "Dokumentáció",
|
||||
"nav_button_publish_message": "Értesítés küldése",
|
||||
"alert_notification_permission_required_button": "Engedélyezés",
|
||||
"alert_not_supported_title": "Az értesítések nincsenek támogatva",
|
||||
"notifications_copied_to_clipboard": "Vágólapra másolva",
|
||||
"nav_button_publish_message": "Értesítés közzététele",
|
||||
"alert_notification_permission_required_button": "Jelentkezz most",
|
||||
"alert_not_supported_title": "Az értesítések nem támogatottak",
|
||||
"notifications_copied_to_clipboard": "A vágólapra másolva",
|
||||
"notifications_tags": "Címkék",
|
||||
"notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét",
|
||||
"notifications_attachment_copy_url_title": "Copy attachment URL to clipboard",
|
||||
"notifications_attachment_copy_url_button": "URL másolása",
|
||||
"notifications_attachment_open_title": "Menjen a(z) {{url}} címre",
|
||||
"notifications_attachment_open_button": "Csatolmány megnyitása",
|
||||
"notifications_attachment_link_expired": "A letöltési link lejárt",
|
||||
"notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le",
|
||||
"nav_button_subscribe": "Feliratkozás témára",
|
||||
"notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét",
|
||||
"notifications_actions_open_url_title": "Menjen a(z) {{url}} címre",
|
||||
"notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban",
|
||||
"notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre",
|
||||
"notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.",
|
||||
"notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.",
|
||||
"notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.",
|
||||
"notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.",
|
||||
"publish_dialog_message_published": "Értesítés elküldve",
|
||||
"notifications_attachment_open_title": "Ugrás a{{url}}-ra",
|
||||
"notifications_attachment_open_button": "Melléklet megnyitása",
|
||||
"notifications_attachment_link_expired": "A letöltési link érvényessége lejárt",
|
||||
"notifications_attachment_link_expires": "A link érvényessége lejár:{{date}}",
|
||||
"nav_button_subscribe": "Iratkozz fel a témára",
|
||||
"notifications_click_copy_url_title": "A link URL-jét a vágólapra másolja",
|
||||
"notifications_actions_open_url_title": "Ugrás a{{url}}-ra",
|
||||
"notifications_actions_not_supported": "Ez a művelet nem támogatott a webalkalmazásban",
|
||||
"notifications_actions_http_request_title": "HTTP-kérés küldése {{method}} címre {{url}}",
|
||||
"notifications_none_for_topic_title": "Erre a témára még nem kaptál értesítést.",
|
||||
"notifications_none_for_any_title": "Nem érkezett hozzád értesítés.",
|
||||
"notifications_none_for_any_description": "Ha értesítéseket szeretnél küldeni egy témához, egyszerűen hajts végre egy PUT vagy POST műveletet a téma URL-jére. Íme egy példa az egyik témádra vonatkozóan.",
|
||||
"notifications_no_subscriptions_title": "Úgy tűnik, még nincs előfizetésed.",
|
||||
"publish_dialog_message_published": "Értesítés közzététele",
|
||||
"notifications_example": "Példa",
|
||||
"notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.",
|
||||
"notifications_no_subscriptions_description": "Kattints a „{{linktext}}” linkre egy téma létrehozásához vagy feliratkozáshoz. Ezt követően PUT vagy POST kéréssel küldhetsz üzeneteket, és itt fogod megkapni az értesítéseket.",
|
||||
"publish_dialog_priority_low": "Alacsony prioritás",
|
||||
"publish_dialog_priority_default": "Közepes prioritás",
|
||||
"publish_dialog_priority_high": "Magas prioritás",
|
||||
"notifications_more_details": "További információkért keresd fel a <websiteLink>weboldalunkat</websiteLink> vagy olvasd el a <docsLink>dokumentációt</docsLink>.",
|
||||
"publish_dialog_title_no_topic": "Értesítés küldése",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)",
|
||||
"publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt",
|
||||
"publish_dialog_priority_min": "Legkisebb prioritás",
|
||||
"publish_dialog_base_url_label": "A szolgáltatás URL-e",
|
||||
"publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com",
|
||||
"publish_dialog_topic_label": "Téma neve",
|
||||
"publish_dialog_priority_max": "Legmagasabb prioritás",
|
||||
"publish_dialog_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
|
||||
"publish_dialog_priority_default": "Alapértelmezett prioritás",
|
||||
"publish_dialog_priority_high": "Kiemelt fontosságú",
|
||||
"notifications_more_details": "További információkért látogasson el a<websiteLink>weboldalra, vagy tekintse meg a</websiteLink>vagy a<docsLink>dokumentációt.",
|
||||
"publish_dialog_title_no_topic": "Értesítés közzététele",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "meghaladja a{{fileSizeLimit}}fájlkorlátot és kvótát,{{remainingBytes}}maradt",
|
||||
"publish_dialog_attachment_limits_quota_reached": "meghaladja a kvótát, {{remainingBytes}} maradt",
|
||||
"publish_dialog_priority_min": "Minimális prioritás",
|
||||
"publish_dialog_base_url_label": "Szolgáltatás URL-je",
|
||||
"publish_dialog_base_url_placeholder": "Szolgáltatás URL-címe, pl. https://example.com",
|
||||
"publish_dialog_topic_label": "A téma neve",
|
||||
"publish_dialog_priority_max": "Legfőbb prioritás",
|
||||
"publish_dialog_topic_placeholder": "Téma neve, pl. phil_alerts",
|
||||
"publish_dialog_title_label": "Cím",
|
||||
"publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely",
|
||||
"publish_dialog_title_placeholder": "Értesítés címe, pl. Lemezterület-figyelmeztetés",
|
||||
"publish_dialog_message_label": "Üzenet",
|
||||
"publish_dialog_message_placeholder": "Írj ide egy üzenetet",
|
||||
"publish_dialog_tags_label": "Címkék",
|
||||
"publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioritás",
|
||||
"publish_dialog_click_label": "URL",
|
||||
"publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com",
|
||||
"publish_dialog_attach_label": "Csatolmány URL-e",
|
||||
"publish_dialog_tags_placeholder": "Vesszővel elválasztott címkék listája, pl.: warning, srv1-backup",
|
||||
"publish_dialog_priority_label": "Elsőbbség",
|
||||
"publish_dialog_click_label": "Kattintson az URL-re",
|
||||
"publish_dialog_click_placeholder": "Az értesítésre kattintáskor megnyíló URL",
|
||||
"publish_dialog_email_label": "E-mail",
|
||||
"publish_dialog_email_placeholder": "A bejelentés továbbításának címe, pl.: phil@example.com",
|
||||
"publish_dialog_attach_label": "A melléklet URL-címe",
|
||||
"publish_dialog_filename_label": "Fájlnév",
|
||||
"publish_dialog_filename_placeholder": "Csatolmány fájlneve",
|
||||
"publish_dialog_filename_placeholder": "A melléklet fájlneve",
|
||||
"publish_dialog_delay_label": "Késleltetés",
|
||||
"publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)",
|
||||
"publish_dialog_other_features": "Egyéb lehetőségek:",
|
||||
"publish_dialog_chip_click_label": "Kattintási URL",
|
||||
"publish_dialog_delay_placeholder": "Szállítás késleltetése, pl. {{unixTimestamp}}, {{relativeTime}}vagy „{{naturalLanguage}}” (csak angolul)",
|
||||
"publish_dialog_other_features": "Egyéb jellemzők:",
|
||||
"publish_dialog_chip_click_label": "Kattintson az URL-re",
|
||||
"publish_dialog_chip_attach_file_label": "Helyi fájl csatolása",
|
||||
"publish_dialog_chip_delay_label": "Késleltetett kézbesítés",
|
||||
"publish_dialog_chip_topic_label": "Téma megváltoztatása",
|
||||
"publish_dialog_button_cancel_sending": "Küldés megállítása",
|
||||
"publish_dialog_button_cancel": "Mégsem",
|
||||
"publish_dialog_checkbox_publish_another": "Küldök még egyet",
|
||||
"publish_dialog_chip_delay_label": "Szállítás késleltetése",
|
||||
"publish_dialog_chip_topic_label": "Téma váltása",
|
||||
"publish_dialog_button_cancel_sending": "Elküldés visszavonása",
|
||||
"publish_dialog_button_cancel": "Mégse",
|
||||
"publish_dialog_checkbox_publish_another": "Újabb közzététel",
|
||||
"publish_dialog_attached_file_title": "Csatolt fájl:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve",
|
||||
"publish_dialog_drop_file_here": "Ejtsd ide a fájlt",
|
||||
"emoji_picker_search_placeholder": "Emoji keresése",
|
||||
"publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a <docsLink>dokumentációt</docsLink>.",
|
||||
"publish_dialog_attached_file_filename_placeholder": "A melléklet fájlneve",
|
||||
"publish_dialog_drop_file_here": "Helyezze ide a fájlt",
|
||||
"emoji_picker_search_placeholder": "Emoji keresés",
|
||||
"publish_dialog_details_examples_description": "Példákért és az összes küldési funkció részletes leírásáért kérjük, olvassa el a <docsLink>dokumentációt</docsLink>.",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Feliratkozás",
|
||||
"subscribe_dialog_login_title": "Be kell jelentkezni",
|
||||
"subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.",
|
||||
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
|
||||
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Iratkozz fel",
|
||||
"subscribe_dialog_login_title": "Bejelentkezés szükséges",
|
||||
"subscribe_dialog_subscribe_description": "A témák nem jelszóval védhetők, ezért válasszon olyan nevet, amelyet nem könnyű kitalálni. A feliratkozás után PUT/POST értesítéseket küldhet.",
|
||||
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Kérjük, adja meg a felhasználónevét és a jelszavát a feliratkozáshoz.",
|
||||
"subscribe_dialog_login_username_label": "Felhasználónév, pl. phil",
|
||||
"subscribe_dialog_login_password_label": "Jelszó",
|
||||
"common_back": "Vissza",
|
||||
"subscribe_dialog_login_button_login": "Belépés",
|
||||
"subscribe_dialog_login_button_login": "Bejelentkezés",
|
||||
"subscribe_dialog_error_user_anonymous": "névtelen",
|
||||
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
|
||||
"prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül",
|
||||
"prefs_notifications_min_priority_description_max": "Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg",
|
||||
"prefs_notifications_min_priority_any": "Bármilyen prioritás",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás",
|
||||
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás",
|
||||
"prefs_notifications_sound_title": "Értesítés hangja",
|
||||
"prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek",
|
||||
"prefs_notifications_sound_no_sound": "Hang nélkül",
|
||||
"prefs_notifications_delete_after_one_week": "1 hét után",
|
||||
"prefs_notifications_delete_after_one_month": "1 hónap után",
|
||||
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve",
|
||||
"prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése",
|
||||
"prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése",
|
||||
"prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.",
|
||||
"subscribe_dialog_error_user_not_authorized": "A{{username}}felhasználó nem rendelkezik jogosultsággal",
|
||||
"prefs_notifications_min_priority_description_any": "Az összes értesítés megjelenítése, prioritástól függetlenül",
|
||||
"prefs_notifications_min_priority_description_max": "Értesítések megjelenítése, ha a prioritás 5 (maximális)",
|
||||
"prefs_notifications_min_priority_any": "Bármely prioritás",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás és annál magasabb",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Magas prioritás és annál magasabb",
|
||||
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritású",
|
||||
"prefs_notifications_sound_title": "Értesítési hang",
|
||||
"prefs_notifications_sound_description_none": "Az értesítések érkezésekor nem hallatszik hang",
|
||||
"prefs_notifications_sound_no_sound": "Nincs hang",
|
||||
"prefs_notifications_delete_after_one_week": "Egy hét elteltével",
|
||||
"prefs_notifications_delete_after_one_month": "Egy hónap elteltével",
|
||||
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem kerülnek automatikusan törlésre",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Az értesítések három óra elteltével automatikusan törlődnek",
|
||||
"prefs_notifications_delete_after_one_day_description": "Az értesítések egy nap elteltével automatikusan törlődnek",
|
||||
"prefs_users_description": "Itt adhat hozzá vagy távolíthat el felhasználókat a védett témákhoz. Felhívjuk figyelmét, hogy a felhasználónév és a jelszó a böngésző helyi tárolójában kerülnek elmentésre.",
|
||||
"prefs_users_table_user_header": "Felhasználó",
|
||||
"prefs_users_table_base_url_header": "Szerver címe",
|
||||
"prefs_users_table_base_url_header": "Szolgáltatás URL-je",
|
||||
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
|
||||
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
|
||||
"prefs_users_dialog_username_label": "Felhasználónév, pl. phil",
|
||||
"prefs_users_dialog_password_label": "Jelszó",
|
||||
"common_add": "Hozzáadás",
|
||||
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
|
||||
"prefs_users_dialog_base_url_label": "Szolgáltatási URL, pl. https://ntfy.sh",
|
||||
"notifications_loading": "Értesítések betöltése …",
|
||||
"publish_dialog_progress_uploading": "Feltöltés …",
|
||||
"notifications_click_copy_url_button": "Hivatkozás másolása",
|
||||
"notifications_click_open_button": "Hivatkozás megnyitása",
|
||||
"publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.",
|
||||
"prefs_notifications_delete_after_one_day": "1 nap után",
|
||||
"publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_email_label": "Továbbítás email-ben",
|
||||
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel",
|
||||
"publish_dialog_progress_uploading": "Feltöltés…",
|
||||
"notifications_click_copy_url_button": "Link másolása",
|
||||
"notifications_click_open_button": "Link megnyitása",
|
||||
"publish_dialog_progress_uploading_detail": "{{loaded}}/{{total}}feltöltése ({{percent}}%) …",
|
||||
"notifications_none_for_topic_description": "Ha értesítéseket szeretnél küldeni erre a témára, egyszerűen hajts végre egy PUT vagy POST műveletet a téma URL-jére.",
|
||||
"prefs_notifications_delete_after_one_day": "Egy nap elteltével",
|
||||
"publish_dialog_attach_placeholder": "Fájl csatolása URL-címen keresztül, pl. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_email_label": "Továbbítás e-mailben",
|
||||
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-címen keresztül",
|
||||
"publish_dialog_button_send": "Küldés",
|
||||
"subscribe_dialog_subscribe_title": "Feliratkozás témára",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Mégsem",
|
||||
"prefs_notifications_min_priority_title": "Legkisebb megjelenítendő prioritás",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb",
|
||||
"prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése",
|
||||
"subscribe_dialog_subscribe_title": "Iratkozz fel a témára",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Mégse",
|
||||
"prefs_notifications_min_priority_title": "Legalacsonyabb prioritás",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Értesítéseket jelenít meg, ha a prioritás {{number}} ({{name}}) vagy annál magasabb",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Alapértelmezett prioritás és annál magasabb",
|
||||
"prefs_notifications_delete_after_one_week_description": "Az értesítések egy hét elteltével automatikusan törlődnek",
|
||||
"prefs_users_add_button": "Felhasználó hozzáadása",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl. phil_alerts",
|
||||
"prefs_notifications_title": "Értesítések",
|
||||
"error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása",
|
||||
"prefs_notifications_delete_after_title": "Régi értesítések törlése",
|
||||
"prefs_notifications_delete_after_three_hours": "3 óra után",
|
||||
"error_boundary_title": "Jaj ne, az ntfy összeomlott",
|
||||
"error_boundary_button_copy_stack_trace": "A veremnyomtatvány másolása",
|
||||
"prefs_notifications_delete_after_title": "Értesítések törlése",
|
||||
"prefs_notifications_delete_after_three_hours": "Három óra múlva",
|
||||
"error_boundary_title": "Jaj, ne, az ntfy összeomlott",
|
||||
"prefs_notifications_delete_after_never": "Soha",
|
||||
"prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése",
|
||||
"prefs_notifications_delete_after_one_month_description": "Az értesítések egy hónap elteltével automatikusan törlődnek",
|
||||
"prefs_appearance_title": "Megjelenés",
|
||||
"priority_default": "közepes",
|
||||
"priority_default": "alapértelmezett",
|
||||
"priority_high": "magas",
|
||||
"priority_max": "legmagasabb",
|
||||
"priority_min": "legkisebb",
|
||||
"error_boundary_gathering_info": "Több információ…",
|
||||
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
|
||||
"priority_max": "legnagyobb",
|
||||
"priority_min": "Nekem",
|
||||
"error_boundary_gathering_info": "További információk …",
|
||||
"publish_dialog_attachment_limits_file_reached": "meghaladja a{{fileSizeLimit}}fájlkorlátot",
|
||||
"prefs_users_title": "Felhasználók kezelése",
|
||||
"common_cancel": "Mégsem",
|
||||
"common_cancel": "Mégse",
|
||||
"common_save": "Mentés",
|
||||
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
|
||||
"prefs_appearance_language_title": "Nyelv",
|
||||
"priority_low": "alacsony",
|
||||
"error_boundary_stack_trace": "Verem nyomkövetés",
|
||||
"publish_dialog_title_topic": "A {{topic}} téma értesítése",
|
||||
"prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni",
|
||||
"error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.<br/>Ha van egy perced, <githubLink>jelentsd be GitHubon</githubLink>, vagy tudasd velünk <discordLink>Discordon</discordLink>, vagy <matrixLink>Matrixon</matrixLink>.",
|
||||
"action_bar_show_menu": "Menü mutatása",
|
||||
"action_bar_toggle_mute": "Üzenetek némítása/bekapcsolása",
|
||||
"error_boundary_stack_trace": "Hibajelentés",
|
||||
"publish_dialog_title_topic": "Közzététel a {{topic}}-ban",
|
||||
"prefs_notifications_sound_description_some": "Az értesítések érkezéskor a {{sound}} hangot játsszák le",
|
||||
"error_boundary_description": "Ez természetesen nem lenne szabad, hogy megtörténjen. Nagyon sajnáljuk a kellemetlenséget.<br/>Ha van egy perced, kérlek, <githubLink>jelentsd be a hibát a GitHubon</githubLink>, vagy értesíts minket a <discordLink>Discordon</discordLink> vagy a <matrixLink>Matrixon</matrixLink>.",
|
||||
"action_bar_show_menu": "Menü megjelenítése",
|
||||
"action_bar_toggle_mute": "Értesítések némítása/némításának feloldása",
|
||||
"notifications_list_item": "Értesítés",
|
||||
"error_boundary_unsupported_indexeddb_description": "A ntfy web alkalmazás működéséhez szükséges az IndexedDB funkció, az ön böngészője nem támogatja az IndexedDB használatát privát böngészés közben.<br/><br/>Miközben privát mód sajnos nem lehetséges, szeretnénk értesíteni hogy magabiztosan használhatja normál módban mert a böngésző minden adatot az ön gépén tárol. Tovább tájékozódhat <githubLink>ezen a Github oldalon</githubLink>, vagy beszéljen velünk <discordLink>Discord-on</discordLink> vagy <matrixLink>Matrix-on</matrixLink>.",
|
||||
"notifications_priority_x": "Prioritás {{prioritás}}",
|
||||
"message_bar_show_dialog": "Küldött üzenetek megjelenítése",
|
||||
"error_boundary_unsupported_indexeddb_description": "Az ntfy webalkalmazás működéséhez szükség van az IndexedDB-re, a böngésződ azonban nem támogatja az IndexedDB-t inkognitó módban.<br/><br/>Bár ez sajnálatos, valójában nem is lenne túl értelmes az ntfy webalkalmazást inkognitó módban használni, mivel minden a böngésző tárolójában kerül elmentésre. További információkat erről a GitHub-problémában találsz, vagy lépj kapcsolatba velünk a Discordon vagy a Matrixon.",
|
||||
"notifications_priority_x": "Prioritás {{priority}}",
|
||||
"message_bar_show_dialog": "Közzétételi párbeszédpanel megjelenítése",
|
||||
"action_bar_logo_alt": "ntfy logó",
|
||||
"action_bar_toggle_action_menu": "Tevékenységkezelő nyitása/zárása",
|
||||
"message_bar_publish": "Üzenet küldése",
|
||||
"nav_button_muted": "Értesítések némítva",
|
||||
"nav_button_connecting": "csatlakozás",
|
||||
"notifications_list": "Értesítés lista",
|
||||
"notifications_mark_read": "Jelölés olvasottként",
|
||||
"action_bar_toggle_action_menu": "Műveleti menü megnyitása/bezárása",
|
||||
"message_bar_publish": "Üzenet közzététele",
|
||||
"nav_button_muted": "Értesítések elnémítva",
|
||||
"nav_button_connecting": "összekötő",
|
||||
"notifications_list": "Értesítések listája",
|
||||
"notifications_mark_read": "Olvasottként jelölés",
|
||||
"notifications_delete": "Törlés",
|
||||
"notifications_new_indicator": "Új értesítés",
|
||||
"notifications_attachment_image": "Csatolt kép",
|
||||
"notifications_attachment_file_image": "Kép fájl",
|
||||
"notifications_attachment_file_video": "Videó fájl",
|
||||
"notifications_attachment_file_audio": "Hang fájl",
|
||||
"notifications_attachment_file_app": "Android alkalmazás fájl",
|
||||
"notifications_attachment_image": "Melléklet kép",
|
||||
"notifications_attachment_file_image": "képfájl",
|
||||
"notifications_attachment_file_video": "videofájl",
|
||||
"notifications_attachment_file_audio": "hangfájl",
|
||||
"notifications_attachment_file_app": "Android-alkalmazásfájl",
|
||||
"notifications_attachment_file_document": "egyéb dokumentum",
|
||||
"publish_dialog_emoji_picker_show": "Emoji kiválasztása",
|
||||
"publish_dialog_topic_reset": "Téma visszaállítása",
|
||||
"publish_dialog_click_reset": "URL kattintás törlése",
|
||||
"publish_dialog_email_reset": "Email továbbítás törlése",
|
||||
"publish_dialog_attach_reset": "Csatolt URL törlése",
|
||||
"publish_dialog_delay_reset": "Késleltetett kézbesítés törlése",
|
||||
"publish_dialog_attached_file_remove": "Csatolt fájl törlése",
|
||||
"publish_dialog_click_reset": "Az URL-re kattintás eltávolítása",
|
||||
"publish_dialog_email_reset": "Az e-mail továbbításának megszüntetése",
|
||||
"publish_dialog_attach_reset": "A melléklet URL-jének eltávolítása",
|
||||
"publish_dialog_delay_reset": "A késleltetett kézbesítés eltávolítása",
|
||||
"publish_dialog_attached_file_remove": "A csatolt fájl eltávolítása",
|
||||
"emoji_picker_search_clear": "Keresés törlése",
|
||||
"prefs_notifications_sound_play": "Kijelölt hang lejátszása",
|
||||
"prefs_users_table": "Felhasználó táblázat",
|
||||
"prefs_notifications_sound_play": "A kiválasztott hang lejátszása",
|
||||
"prefs_users_table": "Felhasználók táblázata",
|
||||
"prefs_users_edit_button": "Felhasználó szerkesztése",
|
||||
"prefs_users_delete_button": "Felhasználó törlése",
|
||||
"error_boundary_unsupported_indexeddb_title": "Privát böngészés nem támogatott",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Szolgáltató URL",
|
||||
"error_boundary_unsupported_indexeddb_title": "A magánböngészés nem támogatott",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Szolgáltatás URL-je",
|
||||
"signup_form_username": "Felhasználónév",
|
||||
"signup_form_password": "Jelszó",
|
||||
"signup_form_button_submit": "Regisztráció",
|
||||
"signup_form_button_submit": "Regisztrálj",
|
||||
"login_form_button_submit": "Bejelentkezés",
|
||||
"login_link_signup": "Regisztráció",
|
||||
"login_disabled": "Bejelentkezés kikapcsolva",
|
||||
"action_bar_change_display_name": "Megjelenített név módosítása",
|
||||
"login_link_signup": "Regisztrálj",
|
||||
"login_disabled": "A bejelentkezés le van tiltva",
|
||||
"action_bar_change_display_name": "A megjelenített név módosítása",
|
||||
"action_bar_profile_logout": "Kijelentkezés",
|
||||
"action_bar_sign_in": "Bejelentkezés",
|
||||
"action_bar_sign_up": "Regisztráció",
|
||||
"action_bar_sign_up": "Regisztrálj",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"nav_button_account": "Fiók",
|
||||
"common_copy_to_clipboard": "Másolás vágólapra",
|
||||
"action_bar_reservation_limit_reached": "Limit elérve",
|
||||
"login_title": "Jelentkezz be a ntfy felhasználódba",
|
||||
"signup_title": "Hozz létre egy ntfy felhasználói fiókot",
|
||||
"common_copy_to_clipboard": "Másolás a vágólapra",
|
||||
"action_bar_reservation_limit_reached": "Elérte a határt",
|
||||
"login_title": "Jelentkezzen be az ntfy-fiókjába",
|
||||
"signup_title": "Hozzon létre egy ntfy-fiókot",
|
||||
"signup_form_confirm_password": "Jelszó megerősítése",
|
||||
"signup_already_have_account": "Már van felhasználód? Jelentkezz be!",
|
||||
"signup_already_have_account": "Van már fiókod? Jelentkezz be!",
|
||||
"action_bar_account": "Fiók",
|
||||
"action_bar_profile_settings": "Beállítások",
|
||||
"signup_error_username_taken": "A felhasználónév {{username}} már foglalt",
|
||||
"signup_error_creation_limit_reached": "Felhasználói regisztráció limit elérve",
|
||||
"signup_error_username_taken": "A{{username}}felhasználónév már foglalt",
|
||||
"signup_error_creation_limit_reached": "Elérte a fiók létrehozásának korlátját",
|
||||
"action_bar_mute_notifications": "Értesítések némítása",
|
||||
"action_bar_unmute_notifications": "Értesítések némításának feloldása",
|
||||
"alert_notification_permission_denied_title": "Az értesítések blokkolva vannak",
|
||||
"alert_notification_permission_denied_description": "Kérjük kapcsold őket vissza a böngésződben",
|
||||
"alert_notification_ios_install_required_title": "iOS telepítés szükséges",
|
||||
"alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a <mdnLink>Notifications API</mdnLink> korlátozása.",
|
||||
"signup_form_toggle_password_visibility": "Jelszó láthatóságának kapcsolása",
|
||||
"action_bar_unmute_notifications": "Értesítések hangjának visszaállítása",
|
||||
"alert_notification_permission_denied_title": "Az értesítések letiltva vannak",
|
||||
"alert_notification_permission_denied_description": "Kérjük, kapcsolja be őket újra a böngészőjében",
|
||||
"alert_notification_ios_install_required_title": "iOS-telepítés szükséges",
|
||||
"alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a <mdnLink>Értesítési API</mdnLink>korlátozása.",
|
||||
"signup_form_toggle_password_visibility": "A jelszó láthatóságának beállítása",
|
||||
"signup_disabled": "A regisztráció le van tiltva",
|
||||
"action_bar_reservation_add": "Téma fenntartása",
|
||||
"action_bar_reservation_add": "Téma elmentése",
|
||||
"action_bar_reservation_edit": "Foglalás módosítása",
|
||||
"action_bar_reservation_delete": "Foglalás törlése",
|
||||
"nav_upgrade_banner_label": "Frissítés ntfy Pro-ra",
|
||||
"nav_upgrade_banner_description": "Témák, több üzenet és e-mail, valamint nagyobb mellékletek megőrzése",
|
||||
"alert_notification_ios_install_required_description": "Kattintson a Megosztás ikonra, majd a Hozzáadás a kezdőképernyőhöz gombra, hogy engedélyezze az értesítéseket iOS rendszeren"
|
||||
"nav_upgrade_banner_label": "Frissíts az ntfy Pro-ra",
|
||||
"nav_upgrade_banner_description": "További témák, több üzenet és e-mail, valamint nagyobb mellékletek",
|
||||
"alert_notification_ios_install_required_description": "Kattints a Megosztás ikonra, majd a „Hozzáadás a kezdőképernyőhöz” gombra az értesítések engedélyezéséhez iOS rendszeren",
|
||||
"notifications_actions_failed_notification": "Sikertelen művelet",
|
||||
"display_name_dialog_title": "A megjelenített név módosítása",
|
||||
"display_name_dialog_description": "Állítson be egy alternatív nevet egy témához, amely az előfizetési listában jelenik meg. Ez megkönnyíti a bonyolult nevű témák azonosítását.",
|
||||
"display_name_dialog_placeholder": "Megjelenítendő név",
|
||||
"reserve_dialog_checkbox_label": "Téma lefoglalása és a hozzáférés beállítása",
|
||||
"publish_dialog_call_label": "Telefonhívás",
|
||||
"publish_dialog_call_item": "Hívja a{{number}}telefonszámot",
|
||||
"publish_dialog_call_reset": "Hívás törlése",
|
||||
"publish_dialog_chip_call_label": "Telefonhívás",
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Nincs ellenőrzött telefonszám",
|
||||
"publish_dialog_checkbox_markdown": "Markdown formátum",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "A webalkalmazás bezárása esetén a többi szerverről érkező értesítések nem érkeznek meg",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Név generálása",
|
||||
"subscribe_dialog_error_topic_already_reserved": "A téma már lefoglalva",
|
||||
"account_basics_title": "Fiók",
|
||||
"account_basics_username_title": "Felhasználónév",
|
||||
"account_basics_username_description": "Hé, ez te vagy ❤",
|
||||
"account_basics_username_admin_tooltip": "Ön rendszergazda",
|
||||
"account_basics_password_title": "Jelszó",
|
||||
"account_basics_password_description": "Fiókjának jelszavának módosítása",
|
||||
"account_basics_password_dialog_title": "Jelszó módosítása",
|
||||
"account_basics_password_dialog_current_password_label": "Jelenlegi jelszó",
|
||||
"account_basics_password_dialog_new_password_label": "Új jelszó",
|
||||
"account_basics_password_dialog_confirm_password_label": "Jelszó megerősítése",
|
||||
"account_basics_password_dialog_button_submit": "Jelszó módosítása",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Helytelen jelszó",
|
||||
"account_basics_phone_numbers_title": "Telefonszámok",
|
||||
"account_basics_phone_numbers_dialog_description": "A hívásértesítési funkció használatához legalább egy telefonszámot hozzá kell adnia és igazolnia kell. Az igazolás SMS-ben vagy telefonhívás útján történhet.",
|
||||
"account_basics_phone_numbers_description": "Telefonos értesítések esetén",
|
||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Még nincs telefonszám",
|
||||
"account_basics_phone_numbers_copied_to_clipboard": "A telefonszámot a vágólapra másoltam",
|
||||
"account_basics_phone_numbers_dialog_title": "Telefonszám hozzáadása",
|
||||
"account_basics_phone_numbers_dialog_number_label": "Telefonszám",
|
||||
"account_basics_phone_numbers_dialog_number_placeholder": "pl. +1222333444",
|
||||
"account_basics_phone_numbers_dialog_verify_button_sms": "SMS küldése",
|
||||
"account_basics_phone_numbers_dialog_verify_button_call": "Hívj fel",
|
||||
"account_basics_phone_numbers_dialog_code_label": "Ellenőrző kód",
|
||||
"account_basics_phone_numbers_dialog_code_placeholder": "pl. 123456",
|
||||
"account_basics_phone_numbers_dialog_check_verification_button": "Kód megerősítése",
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||
"account_basics_phone_numbers_dialog_channel_call": "Hívás",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "A hozzárendelt felhasználót nem lehet szerkeszteni vagy törölni",
|
||||
"account_usage_title": "Használat",
|
||||
"account_usage_of_limit": "a{{limit}}",
|
||||
"account_usage_unlimited": "Korlátlan",
|
||||
"account_usage_limits_reset_daily": "A használati korlátok minden nap éjfélkor (UTC) visszaállnak",
|
||||
"account_basics_tier_title": "Számlatípus",
|
||||
"account_basics_tier_description": "Fiókod erősségi szintje",
|
||||
"account_basics_tier_admin": "Admin",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(a{{tier}}szinttel)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(nincs besorolás)",
|
||||
"account_basics_tier_basic": "Alapvető",
|
||||
"account_basics_tier_free": "Ingyenes",
|
||||
"account_basics_tier_interval_monthly": "havonta",
|
||||
"account_basics_tier_interval_yearly": "éves szinten",
|
||||
"account_basics_tier_upgrade_button": "Frissíts Pro verzióra",
|
||||
"account_basics_tier_change_button": "Változás",
|
||||
"account_basics_tier_paid_until": "Az előfizetés{{date}}-ig fizetve, és automatikusan megújul",
|
||||
"account_basics_tier_payment_overdue": "A fizetési határidő lejárt. Kérjük, frissítse a fizetési módját, ellenkező esetben fiókját hamarosan alacsonyabb szintre soroljuk át.",
|
||||
"account_basics_tier_canceled_subscription": "Előfizetését töröltük, és{{date}}-tól ingyenes fiókra váltunk.",
|
||||
"account_basics_tier_manage_billing_button": "Számlázás kezelése",
|
||||
"account_usage_messages_title": "Közzétett üzenetek",
|
||||
"account_usage_emails_title": "Elküldött e-mailek",
|
||||
"account_usage_calls_title": "Kezdeményezett telefonhívások",
|
||||
"account_usage_calls_none": "Ebből a fiókból nem lehet telefonálni",
|
||||
"account_usage_reservations_title": "Fenntartott témák",
|
||||
"account_usage_reservations_none": "Ehhez a fiókhoz nincs fenntartott téma",
|
||||
"account_usage_attachment_storage_title": "Mellékletek tárolása",
|
||||
"account_usage_attachment_storage_description": "{{filesize}}fájlonként, törlésre kerül{{expiry}}után",
|
||||
"account_usage_basis_ip_description": "A fiók használati statisztikái és korlátai az Ön IP-címén alapulnak, ezért előfordulhat, hogy más felhasználókkal is megosztásra kerülnek. A fent feltüntetett korlátok a jelenlegi sávszélesség-korlátozások alapján számított hozzávetőleges értékek.",
|
||||
"account_usage_cannot_create_portal_session": "A számlázási portál nem nyitható meg",
|
||||
"account_delete_title": "Fiók törlése",
|
||||
"account_delete_description": "Fiókjának végleges törlése",
|
||||
"account_delete_dialog_description": "Ezzel véglegesen törlöd a fiókodat, beleértve a szerveren tárolt összes adatot is. A törlés után a felhasználóneved 7 napig nem lesz elérhető. Ha biztosan folytatni szeretnéd, kérjük, erősítsd meg a jelszavadat az alábbi mezőben.",
|
||||
"account_delete_dialog_label": "Jelszó",
|
||||
"account_delete_dialog_button_cancel": "Mégse",
|
||||
"account_delete_dialog_button_submit": "Fiók végleges törlése",
|
||||
"account_delete_dialog_billing_warning": "A fiók törlésével a fizetési előfizetés is azonnal megszűnik. Ezt követően már nem fogsz hozzáférni a fizetési irányítópanelhez.",
|
||||
"account_upgrade_dialog_title": "Fiókcsomag módosítása",
|
||||
"account_upgrade_dialog_interval_monthly": "Havi",
|
||||
"account_upgrade_dialog_interval_yearly": "Évente",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "{{discount}}% mentése",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "akár {{discount}}%-os megtakarítás",
|
||||
"account_upgrade_dialog_cancel_warning": "Ezzel <strong>előfizetése megszűnik</strong>, és fiókja alacsonyabb szintre kerül {{date}}. Ezen a napon a témákhoz tartozó foglalások, valamint a szerveren tárolt üzenetek <strong>törlésre kerülnek</strong>.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Arányos elszámolás</strong>: Fizetős csomagok közötti áttérés esetén az árkülönbözetet<strong>azonnal felszámítjuk</strong>. Alacsonyabb csomagra való áttérés esetén a fennmaradó egyenleget a jövőbeli számlázási időszakok fedezésére használjuk fel.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "A kiválasztott csomag kevesebb témafoglalást engedélyez, mint a jelenlegi csomagod. A csomagváltás előtt <strong>kérjük, törölj legalább egy foglalást</strong>. A foglalásokat a <Link>Beállítások</Link>menüpontban törölheted.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "A kiválasztott csomag kevesebb témafoglalást engedélyez, mint a jelenlegi csomagod. A csomagváltás előtt <strong>kérjük, törölj legalább {{count}} foglalást</strong>. A foglalásokat a <Link>Beállítások</Link>menüpontban törölheted.",
|
||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} fenntartott téma",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} fenntartott témák",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Nincsenek fenntartott témák",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} napi üzenet",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} napi üzenetek",
|
||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} napi hírlevél",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} napi e-mailek",
|
||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} napi telefonhívás",
|
||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} napi telefonhívás",
|
||||
"account_upgrade_dialog_tier_features_no_calls": "Tilos telefonálni",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} fájlonként",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} teljes tárhely",
|
||||
"account_upgrade_dialog_tier_price_per_month": "hónap",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}}évente. Havonta számlázzuk.",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} éves számlázás. Megtakarítás: {{save}}.",
|
||||
"account_upgrade_dialog_tier_selected_label": "Kiválasztott",
|
||||
"account_upgrade_dialog_tier_current_label": "Jelenlegi",
|
||||
"account_upgrade_dialog_billing_contact_email": "Számlázással kapcsolatos kérdéseivel kérjük, forduljon közvetlenül hozzánk.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Számlázással kapcsolatos kérdéseivel kérjük, keresse fel a<Link>weboldalunkat</Link>.",
|
||||
"account_upgrade_dialog_button_cancel": "Mégse",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Regisztrálj most",
|
||||
"account_upgrade_dialog_button_pay_now": "Fizessen most, és iratkozzon fel",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Előfizetés lemondása",
|
||||
"account_upgrade_dialog_button_update_subscription": "Előfizetés frissítése",
|
||||
"account_tokens_title": "Hozzáférési tokenek",
|
||||
"account_tokens_description": "Az ntfy API-n keresztül történő közzététel és feliratkozás során használjon hozzáférési tokeneket, így nem kell megadnia a fiókja bejelentkezési adatait. További információkért tekintse meg a <Link>dokumentációt</Link>.",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_tokens_table_label_header": "Címke",
|
||||
"account_tokens_table_last_access_header": "Utolsó hozzáférés",
|
||||
"account_tokens_table_expires_header": "Lejár",
|
||||
"account_tokens_table_never_expires": "Soha nem jár le",
|
||||
"account_tokens_table_current_session": "Aktuális böngészőmunkamenet",
|
||||
"account_tokens_table_copied_to_clipboard": "Az hozzáférési token másolva",
|
||||
"account_tokens_table_cannot_delete_or_edit": "A jelenlegi munkamenet-tokent nem lehet szerkeszteni vagy törölni",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "A létrehozott token nem szerkeszthető vagy törölhető",
|
||||
"account_tokens_table_create_token_button": "Hozzon létre hozzáférési tokent",
|
||||
"account_tokens_table_last_origin_tooltip": "A{{ip}}IP-címről kattintson a kereséshez",
|
||||
"account_tokens_dialog_title_create": "Hozzon létre hozzáférési tokent",
|
||||
"account_tokens_dialog_title_edit": "Hozzáférési token szerkesztése",
|
||||
"account_tokens_dialog_title_delete": "Hozzáférési token törlése",
|
||||
"account_tokens_dialog_label": "Címke, pl. Radarr értesítések",
|
||||
"account_tokens_dialog_button_create": "Token létrehozása",
|
||||
"account_tokens_dialog_button_update": "Token frissítése",
|
||||
"account_tokens_dialog_button_cancel": "Mégse",
|
||||
"account_tokens_dialog_expires_label": "Az hozzáférési token érvényessége",
|
||||
"account_tokens_dialog_expires_unchanged": "A lejárati dátumot ne módosítsa",
|
||||
"account_tokens_dialog_expires_x_hours": "A token érvényessége {{hours}} óra múlva lejár",
|
||||
"account_tokens_dialog_expires_x_days": "A token érvényessége {{days}} nap múlva lejár",
|
||||
"account_tokens_dialog_expires_never": "A token soha nem jár le",
|
||||
"account_tokens_delete_dialog_title": "Hozzáférési token törlése",
|
||||
"account_tokens_delete_dialog_description": "Mielőtt törölne egy hozzáférési tokent, győződjön meg arról, hogy egyetlen alkalmazás vagy szkript sem használja azt éppen. <strong>Ez a művelet visszafordíthatatlan</strong>.",
|
||||
"account_tokens_delete_dialog_submit_button": "A token végleges törlése",
|
||||
"prefs_notifications_web_push_title": "Háttérben futó értesítések",
|
||||
"prefs_notifications_web_push_enabled_description": "Az értesítések akkor is beérkeznek, ha a webalkalmazás nem fut (Web Push segítségével)",
|
||||
"prefs_notifications_web_push_disabled_description": "Értesítéseket kapunk, amikor a webalkalmazás fut (WebSocket-en keresztül)",
|
||||
"prefs_notifications_web_push_enabled": "Engedélyezve a{{server}}esetében",
|
||||
"prefs_notifications_web_push_disabled": "Fogyatékkal élők",
|
||||
"prefs_users_description_no_sync": "A felhasználónevek és jelszavak nem kerülnek szinkronizálásra a fiókjával.",
|
||||
"prefs_users_table_cannot_delete_or_edit": "A bejelentkezett felhasználót nem lehet törölni vagy szerkeszteni",
|
||||
"prefs_appearance_theme_title": "Téma",
|
||||
"prefs_appearance_theme_system": "Rendszer (alapértelmezett)",
|
||||
"prefs_appearance_theme_dark": "Sötét mód",
|
||||
"prefs_appearance_theme_light": "Világos mód",
|
||||
"prefs_reservations_title": "Fenntartott témák",
|
||||
"prefs_reservations_description": "Itt foglalhat le témákat személyes használatra. A téma lefoglalásával tulajdonjogot szerez a témára, és megadhatja a többi felhasználó számára a témához való hozzáférési jogosultságokat.",
|
||||
"prefs_reservations_limit_reached": "Elérted a fenntartott témák számának korlátját.",
|
||||
"prefs_reservations_add_button": "Foglalt téma hozzáadása",
|
||||
"prefs_reservations_edit_button": "Téma szerkesztése",
|
||||
"prefs_reservations_delete_button": "A téma hozzáférésének visszaállítása",
|
||||
"prefs_reservations_table": "Foglalt témák táblázata",
|
||||
"prefs_reservations_table_topic_header": "Téma",
|
||||
"prefs_reservations_table_access_header": "Hozzáférés",
|
||||
"prefs_reservations_table_everyone_deny_all": "Csak én tudok hírleveleket kiadni és feliratkozni rájuk",
|
||||
"prefs_reservations_table_everyone_read_only": "Én is közzétehetek és feliratkozhatok, mindenki feliratkozhat",
|
||||
"prefs_reservations_table_everyone_write_only": "Én is közzétehetek és feliratkozhatok, mindenki közzétehet",
|
||||
"prefs_reservations_table_everyone_read_write": "Bárki közzétehet és feliratkozhat",
|
||||
"prefs_reservations_table_not_subscribed": "Nincs feliratkozva",
|
||||
"prefs_reservations_table_click_to_subscribe": "Kattintson a feliratkozáshoz",
|
||||
"prefs_reservations_dialog_title_add": "Téma elmentése",
|
||||
"prefs_reservations_dialog_title_edit": "Foglalt téma szerkesztése",
|
||||
"prefs_reservations_dialog_title_delete": "Témafoglalás törlése",
|
||||
"prefs_reservations_dialog_description": "A téma lefoglalásával a téma tulajdonjogát szerezheti meg, és meghatározhatja a többi felhasználó hozzáférési jogosultságait a témához.",
|
||||
"prefs_reservations_dialog_topic_label": "Téma",
|
||||
"prefs_reservations_dialog_access_label": "Hozzáférés",
|
||||
"reservation_delete_dialog_description": "A foglalás törlésével lemondasz a téma feletti tulajdonjogodról, és mások is lefoglalhatják azt. A meglévő üzeneteket és mellékleteket megtarthatod vagy törölheted.",
|
||||
"reservation_delete_dialog_action_keep_title": "A gyorsítótárban tárolt üzenetek és mellékletek megőrzése",
|
||||
"reservation_delete_dialog_action_keep_description": "A szerveren gyorsítótárba mentett üzenetek és mellékletek nyilvánosan láthatóvá válnak azok számára, akik ismerik a téma nevét.",
|
||||
"reservation_delete_dialog_action_delete_title": "A gyorsítótárban tárolt üzenetek és mellékletek törlése",
|
||||
"reservation_delete_dialog_action_delete_description": "A gyorsítótárban tárolt üzenetek és mellékletek véglegesen törlésre kerülnek. Ez a művelet visszafordíthatatlan.",
|
||||
"reservation_delete_dialog_submit_button": "Foglalás törlése",
|
||||
"error_boundary_button_reload_ntfy": "Töltsd be nekem",
|
||||
"web_push_subscription_expiring_title": "Az értesítések felfüggesztésre kerülnek",
|
||||
"web_push_subscription_expiring_body": "Nyissa meg az ntfy alkalmazást, hogy továbbra is értesítéseket kapjon",
|
||||
"web_push_unknown_notification_title": "Ismeretlen értesítés érkezett a szerverről",
|
||||
"web_push_unknown_notification_body": "Előfordulhat, hogy a webalkalmazás megnyitásával frissítenie kell az ntfy-t"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -115,8 +115,8 @@
|
||||
"subscribe_dialog_error_user_anonymous": "匿名",
|
||||
"prefs_notifications_title": "通知",
|
||||
"prefs_notifications_sound_title": "通知提示音",
|
||||
"prefs_notifications_sound_description_none": "收到通知时不播放任何声音",
|
||||
"prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 声音",
|
||||
"prefs_notifications_sound_description_none": "收到通知时不播放任何提示音",
|
||||
"prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 提示音",
|
||||
"prefs_notifications_sound_no_sound": "静音",
|
||||
"prefs_notifications_sound_play": "播放选中声音",
|
||||
"prefs_notifications_min_priority_title": "最低优先级",
|
||||
@@ -152,9 +152,9 @@
|
||||
"prefs_appearance_title": "外观",
|
||||
"prefs_appearance_language_title": "语言",
|
||||
"prefs_appearance_theme_title": "主題",
|
||||
"prefs_appearance_theme_system": "系統 (預設)",
|
||||
"prefs_appearance_theme_dark": "黑暗模式",
|
||||
"prefs_appearance_theme_light": "光亮模式",
|
||||
"prefs_appearance_theme_system": "系统 (默认)",
|
||||
"prefs_appearance_theme_dark": "深色模式",
|
||||
"prefs_appearance_theme_light": "浅色模式",
|
||||
"priority_min": "最低",
|
||||
"priority_low": "低",
|
||||
"priority_default": "默认",
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"account_basics_password_description": "更改你的帳戶密碼",
|
||||
"account_basics_password_dialog_button_submit": "更改密碼",
|
||||
"account_basics_password_description": "變更帳戶密碼",
|
||||
"account_basics_password_dialog_button_submit": "變更密碼",
|
||||
"account_basics_password_dialog_confirm_password_label": "確認密碼",
|
||||
"account_basics_password_dialog_current_password_incorrect": "密碼錯誤",
|
||||
"account_basics_password_dialog_current_password_label": "當前密碼",
|
||||
"account_basics_password_dialog_current_password_label": "目前密碼",
|
||||
"account_basics_password_dialog_new_password_label": "新密碼",
|
||||
"account_basics_password_dialog_title": "更改密碼",
|
||||
"account_basics_password_dialog_title": "變更密碼",
|
||||
"account_basics_password_title": "密碼",
|
||||
"account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼板",
|
||||
"account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼簿",
|
||||
"account_basics_phone_numbers_description": "電話通知",
|
||||
"account_basics_phone_numbers_dialog_channel_call": "撥打",
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "短信",
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "簡訊",
|
||||
"account_basics_phone_numbers_dialog_check_verification_button": "確認碼",
|
||||
"account_basics_phone_numbers_dialog_code_label": "驗證碼",
|
||||
"account_basics_phone_numbers_dialog_code_placeholder": "例如:123456",
|
||||
"account_basics_phone_numbers_dialog_description": "要使用來電通知功能,你需要新增並驗證至少一個電話號碼。可以通過短信或電話驗證。",
|
||||
"account_basics_phone_numbers_dialog_description": "若要使用來電通知功能,請先新增並驗證至少一個電話號碼。你可以透過簡訊或電話完成驗證。",
|
||||
"account_basics_phone_numbers_dialog_number_label": "電話號碼",
|
||||
"account_basics_phone_numbers_dialog_number_placeholder": "例如:+1222333444",
|
||||
"account_basics_phone_numbers_dialog_title": "新增電話號碼",
|
||||
"account_basics_phone_numbers_dialog_verify_button_call": "撥打電話",
|
||||
"account_basics_phone_numbers_dialog_verify_button_sms": "發送資訊",
|
||||
"account_basics_phone_numbers_no_phone_numbers_yet": "無可執行的電話號碼",
|
||||
"account_basics_phone_numbers_dialog_verify_button_sms": "傳送簡訊",
|
||||
"account_basics_phone_numbers_no_phone_numbers_yet": "尚無電話號碼",
|
||||
"account_basics_phone_numbers_title": "電話號碼",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(無等級)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等級)",
|
||||
"account_basics_tier_admin": "管理員",
|
||||
"account_basics_tier_basic": "基礎版",
|
||||
"account_basics_tier_canceled_subscription": "你的訂閱已取消,並將在 {{date}} 降級為免費帳戶。",
|
||||
"account_basics_tier_change_button": "改變",
|
||||
"account_basics_tier_description": "你帳戶的權限級別",
|
||||
"account_basics_tier_change_button": "變更",
|
||||
"account_basics_tier_description": "你的帳戶權限等級",
|
||||
"account_basics_tier_free": "免費",
|
||||
"account_basics_tier_interval_monthly": "每月",
|
||||
"account_basics_tier_interval_yearly": "每年",
|
||||
@@ -40,149 +40,149 @@
|
||||
"account_basics_title": "帳戶",
|
||||
"account_basics_username_admin_tooltip": "你是管理員",
|
||||
"account_basics_username_description": "嘿,那是你 ❤",
|
||||
"account_basics_username_title": "用戶名",
|
||||
"account_basics_username_title": "使用者名稱",
|
||||
"account_delete_description": "永久刪除你的帳戶",
|
||||
"account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再訪問計費儀錶板。",
|
||||
"account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再存取計費儀錶板。",
|
||||
"account_delete_dialog_button_cancel": "取消",
|
||||
"account_delete_dialog_button_submit": "永久刪除帳戶",
|
||||
"account_delete_dialog_description": "這將永久刪除你的帳戶,包括存儲在伺服器上的所有數據。刪除後,你的用戶名將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。",
|
||||
"account_delete_dialog_description": "這將永久刪除你的帳戶,包括儲存在伺服器上的所有資料。刪除後,你的使用者名稱將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。",
|
||||
"account_delete_dialog_label": "密碼",
|
||||
"account_delete_title": "刪除帳戶",
|
||||
"account_tokens_delete_dialog_description": "在刪除訪問令牌之前,請確保沒有應用程序或腳本正在活躍使用它。 <strong>此操作無法撤銷</strong>。",
|
||||
"account_tokens_delete_dialog_submit_button": "永久删除令牌",
|
||||
"account_tokens_delete_dialog_title": "刪除訪問令牌",
|
||||
"account_tokens_description": "通過 ntfy API 發布和訂閱時使用訪問令牌,因此你不必發送你的帳戶憑證。查看<Link>文檔</Link>以了解更多資訊。",
|
||||
"account_tokens_delete_dialog_description": "在刪除存取權杖之前,請確認沒有應用程式或指令碼正在使用此權杖。<strong>此操作無法復原</strong>。",
|
||||
"account_tokens_delete_dialog_submit_button": "永久刪除權杖",
|
||||
"account_tokens_delete_dialog_title": "刪除存取權杖",
|
||||
"account_tokens_description": "透過 ntfy API 發布和訂閱時,請使用存取權杖,這樣就不必傳送你的帳戶憑證。請參閱<Link>說明</Link>以瞭解更多資訊。",
|
||||
"account_tokens_dialog_button_cancel": "取消",
|
||||
"account_tokens_dialog_button_create": "創建令牌",
|
||||
"account_tokens_dialog_button_update": "更新令牌",
|
||||
"account_tokens_dialog_expires_label": "訪問令牌過期於",
|
||||
"account_tokens_dialog_expires_never": "令牌永不過期",
|
||||
"account_tokens_dialog_button_create": "建立權杖",
|
||||
"account_tokens_dialog_button_update": "更新權杖",
|
||||
"account_tokens_dialog_expires_label": "存取權杖到期時間",
|
||||
"account_tokens_dialog_expires_never": "權杖永不過期",
|
||||
"account_tokens_dialog_expires_unchanged": "保持過期日期不變",
|
||||
"account_tokens_dialog_expires_x_days": "令牌在 {{days}} 天後過期",
|
||||
"account_tokens_dialog_expires_x_hours": "令牌在 {{hours}} 小時後過期",
|
||||
"account_tokens_dialog_expires_x_days": "權杖將在 {{days}} 天後到期",
|
||||
"account_tokens_dialog_expires_x_hours": "權杖將在 {{hours}} 小時後到期",
|
||||
"account_tokens_dialog_label": "標籤,例如:Radarr 通知",
|
||||
"account_tokens_dialog_title_create": "創建訪問令牌",
|
||||
"account_tokens_dialog_title_delete": "刪除訪問令牌",
|
||||
"account_tokens_dialog_title_edit": "編輯訪問令牌",
|
||||
"account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除當前會話令牌",
|
||||
"account_tokens_table_copied_to_clipboard": "已複製訪問令牌",
|
||||
"account_tokens_table_create_token_button": "創建訪問令牌",
|
||||
"account_tokens_table_current_session": "當前瀏覽器會話",
|
||||
"account_tokens_dialog_title_create": "建立存取權杖",
|
||||
"account_tokens_dialog_title_delete": "刪除存取權杖",
|
||||
"account_tokens_dialog_title_edit": "編輯存取權杖",
|
||||
"account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除目前的工作階段權杖",
|
||||
"account_tokens_table_copied_to_clipboard": "已複製存取權杖",
|
||||
"account_tokens_table_create_token_button": "建立存取權杖",
|
||||
"account_tokens_table_current_session": "目前的瀏覽器工作階段",
|
||||
"account_tokens_table_expires_header": "過期",
|
||||
"account_tokens_table_label_header": "標籤",
|
||||
"account_tokens_table_last_access_header": "最後訪問",
|
||||
"account_tokens_table_last_origin_tooltip": "於IP地址 {{ip}},點擊查找",
|
||||
"account_tokens_table_last_access_header": "最後存取",
|
||||
"account_tokens_table_last_origin_tooltip": "來自 IP 位址 {{ip}},點選即可查詢",
|
||||
"account_tokens_table_never_expires": "永不過期",
|
||||
"account_tokens_table_token_header": "令牌",
|
||||
"account_tokens_title": "訪問令牌",
|
||||
"account_upgrade_dialog_billing_contact_email": "有關賬單問題,請直接<Link>聯繫我們 </Link>。",
|
||||
"account_upgrade_dialog_billing_contact_website": "有關賬單問題,請參考我們的<Link>網站 </Link>。",
|
||||
"account_tokens_table_token_header": "權杖",
|
||||
"account_tokens_title": "存取權杖",
|
||||
"account_upgrade_dialog_billing_contact_email": "如有帳單問題,請直接<Link>聯絡我們</Link>。",
|
||||
"account_upgrade_dialog_billing_contact_website": "如有帳單問題,請參考我們的<Link>網站</Link>。",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "取消訂閱",
|
||||
"account_upgrade_dialog_button_cancel": "取消",
|
||||
"account_upgrade_dialog_button_pay_now": "立即付款並訂閱",
|
||||
"account_upgrade_dialog_button_redirect_signup": "立即註冊",
|
||||
"account_upgrade_dialog_button_update_subscription": "更新訂閱",
|
||||
"account_upgrade_dialog_cancel_warning": "這將<strong>取消你的訂閱</strong>,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及緩存在伺服器上的訊息<strong>將被刪除</strong>。",
|
||||
"account_upgrade_dialog_cancel_warning": "這將<strong>取消你的訂閱</strong>,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及快取在伺服器上的訊息<strong>將被刪除</strong>。",
|
||||
"account_upgrade_dialog_interval_monthly": "每月",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "節省高達 {{discount}}%",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "節省 {{discount}}%",
|
||||
"account_upgrade_dialog_interval_yearly": "每年",
|
||||
"account_upgrade_dialog_proration_info": "<strong>按比例分配</strong>:在付費計劃之間升級時,差價將被<strong>立刻收取</strong>。在降級到較低級別時,餘額將被用於支付未來的賬單周期。",
|
||||
"account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,<strong>請至少刪除 1 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。",
|
||||
"account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,<strong>請至少刪除 {{count}} 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。",
|
||||
"account_upgrade_dialog_tier_current_label": "當前",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "每個文件 {{filesize}}",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總存儲空間",
|
||||
"account_upgrade_dialog_tier_features_calls_one": "每日一通電話",
|
||||
"account_upgrade_dialog_tier_features_calls_other": "每日{{calls}} 通電話",
|
||||
"account_upgrade_dialog_tier_features_emails_one": "每日一封郵件",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 條郵件",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "每日一條訊息",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 條訊息",
|
||||
"account_upgrade_dialog_proration_info": "<strong>按比例計費</strong>:升級到其他付費方案時,系統會<strong>立即收取</strong>差價。降級到較低等級時,餘額會用於支付未來的帳單週期。",
|
||||
"account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於目前等級。在變更你的等級之前,<strong>請至少刪除 1 項保留</strong>。你可以在<Link>設定</Link>中移除保留。",
|
||||
"account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於目前等級。在變更你的等級之前,<strong>請至少刪除 {{count}} 項保留</strong>。你可以在<Link>設定</Link>中移除保留。",
|
||||
"account_upgrade_dialog_tier_current_label": "目前",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "每個檔案 {{filesize}}",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總儲存空間",
|
||||
"account_upgrade_dialog_tier_features_calls_one": "每日 {{calls}} 通電話",
|
||||
"account_upgrade_dialog_tier_features_calls_other": "每日 {{calls}} 通電話",
|
||||
"account_upgrade_dialog_tier_features_emails_one": "每日 {{emails}} 封電子郵件",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 封電子郵件",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "每日 {{messages}} 則訊息",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 則訊息",
|
||||
"account_upgrade_dialog_tier_features_no_calls": "沒有電話",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "無保留主題",
|
||||
"account_upgrade_dialog_tier_features_reservations_one": "保留一條主題",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 條主題",
|
||||
"account_upgrade_dialog_tier_features_reservations_one": "保留 {{reservations}} 個主題",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 個主題",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月計費。",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{價格}} 按年計費。節省 {{save}}。",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} 按年計費。節省 {{save}}。",
|
||||
"account_upgrade_dialog_tier_price_per_month": "月",
|
||||
"account_upgrade_dialog_tier_selected_label": "已選",
|
||||
"account_upgrade_dialog_title": "更改帳戶等級",
|
||||
"account_usage_attachment_storage_description": "每個文件 {{filesize}},在 {{expiry}} 後刪除",
|
||||
"account_usage_attachment_storage_title": "附件存儲",
|
||||
"account_usage_basis_ip_description": "此帳戶的使用統計資訊和限制基於你的 IP 地址,因此可能會與其他用戶共享。上面顯示的限制是基於現有速率限制的近似值。",
|
||||
"account_upgrade_dialog_title": "變更帳戶等級",
|
||||
"account_usage_attachment_storage_description": "每個檔案 {{filesize}},在 {{expiry}} 後刪除",
|
||||
"account_usage_attachment_storage_title": "附件儲存",
|
||||
"account_usage_basis_ip_description": "此帳戶會依你的 IP 位址計算使用統計與限制,因此可能會與其他使用者共用。上方限制是根據現有速率限制估算的近似值。",
|
||||
"account_usage_calls_none": "此帳號無法撥打電話",
|
||||
"account_usage_calls_title": "已撥打電話",
|
||||
"account_usage_cannot_create_portal_session": "無法打開計費門戶",
|
||||
"account_usage_emails_title": "已發送電子郵件",
|
||||
"account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置",
|
||||
"account_usage_cannot_create_portal_session": "無法開啟計費入口網站",
|
||||
"account_usage_emails_title": "已傳送電子郵件",
|
||||
"account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重設",
|
||||
"account_usage_messages_title": "已發布訊息",
|
||||
"account_usage_of_limit": "{{limit}} 的",
|
||||
"account_usage_of_limit": "/ {{limit}}",
|
||||
"account_usage_reservations_none": "此帳戶沒有保留主題",
|
||||
"account_usage_reservations_title": "保留主題",
|
||||
"account_usage_title": "使用量",
|
||||
"account_usage_unlimited": "無限",
|
||||
"action_bar_account": "帳戶",
|
||||
"action_bar_change_display_name": "更改顯示名稱",
|
||||
"action_bar_change_display_name": "變更顯示名稱",
|
||||
"action_bar_clear_notifications": "清除所有通知",
|
||||
"action_bar_logo_alt": "ntfy 標識",
|
||||
"action_bar_logo_alt": "ntfy 標誌",
|
||||
"action_bar_mute_notifications": "靜音",
|
||||
"action_bar_profile_logout": "登出",
|
||||
"action_bar_profile_settings": "設定",
|
||||
"action_bar_profile_title": "個人資料",
|
||||
"action_bar_reservation_add": "保留主題",
|
||||
"action_bar_reservation_delete": "移除保留",
|
||||
"action_bar_reservation_edit": "更改保留",
|
||||
"action_bar_reservation_edit": "變更保留",
|
||||
"action_bar_reservation_limit_reached": "達到限制",
|
||||
"action_bar_send_test_notification": "發送測試通知",
|
||||
"action_bar_send_test_notification": "傳送測試通知",
|
||||
"action_bar_settings": "設定",
|
||||
"action_bar_show_menu": "顯示選單",
|
||||
"action_bar_sign_in": "登錄",
|
||||
"action_bar_sign_in": "登入",
|
||||
"action_bar_sign_up": "註冊",
|
||||
"action_bar_toggle_action_menu": "開啟或關閉操作選單",
|
||||
"action_bar_toggle_mute": "通知靜音/解除通知靜音",
|
||||
"action_bar_unmute_notifications": "取消靜音",
|
||||
"action_bar_unsubscribe": "取消訂閱",
|
||||
"alert_notification_ios_install_required_description": "要接收通知,請在 iOS 上點擊共享,然後添加到主屏幕",
|
||||
"alert_notification_ios_install_required_description": "若要接收通知,請在 iOS 上點選 [分享],然後選擇 [加入主畫面]",
|
||||
"alert_notification_ios_install_required_title": "需要安裝 iOS 應用程式",
|
||||
"alert_notification_permission_denied_description": "你已禁用通知。要重新啟用通知,請在瀏覽器設置中啟用通知",
|
||||
"alert_notification_permission_denied_title": "已禁用通知",
|
||||
"alert_notification_permission_denied_description": "你已停用通知。若要重新啟用通知,請在瀏覽器設定中開啟通知",
|
||||
"alert_notification_permission_denied_title": "已停用通知",
|
||||
"alert_notification_permission_required_button": "現在授予",
|
||||
"alert_notification_permission_required_description": "授予瀏覽器顯示桌面通知的權限",
|
||||
"alert_notification_permission_required_title": "已禁用通知",
|
||||
"alert_notification_permission_required_description": "請授予瀏覽器顯示桌面通知的權限",
|
||||
"alert_notification_permission_required_title": "已停用通知",
|
||||
"alert_not_supported_context_description": "通知僅支援 HTTPS。這是 <mdnLink>Notifications API</mdnLink> 的限制。",
|
||||
"alert_not_supported_description": "你的瀏覽器不支援通知",
|
||||
"alert_not_supported_title": "不支援通知",
|
||||
"common_add": "新增",
|
||||
"common_back": "返回",
|
||||
"common_back": "上一頁",
|
||||
"common_cancel": "取消",
|
||||
"common_copy_to_clipboard": "複製到剪貼板",
|
||||
"common_save": "保存",
|
||||
"display_name_dialog_description": "為訂閱列表中顯示的主題設置一個替代名稱。這有助於更輕鬆地識別名稱複雜的主題。",
|
||||
"common_copy_to_clipboard": "複製到剪貼簿",
|
||||
"common_save": "儲存",
|
||||
"display_name_dialog_description": "為訂閱清單中顯示的主題設定替代名稱。這有助於更輕鬆地識別名稱複雜的主題。",
|
||||
"display_name_dialog_placeholder": "顯示名稱",
|
||||
"display_name_dialog_title": "更改顯示名稱",
|
||||
"emoji_picker_search_clear": "清除搜索",
|
||||
"emoji_picker_search_placeholder": "查找表情符號",
|
||||
"error_boundary_button_copy_stack_trace": "複製堆疊追踪",
|
||||
"error_boundary_button_reload_ntfy": "重新加載 ntfy",
|
||||
"error_boundary_description": "這顯然不應該發生。對此非常抱歉。<br/>如果你有時間,請<githubLink>在GitHub</githubLink>上報告,或通過<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告訴我們。",
|
||||
"display_name_dialog_title": "變更顯示名稱",
|
||||
"emoji_picker_search_clear": "清除搜尋",
|
||||
"emoji_picker_search_placeholder": "搜尋表情符號",
|
||||
"error_boundary_button_copy_stack_trace": "複製堆疊追蹤",
|
||||
"error_boundary_button_reload_ntfy": "重新載入 ntfy",
|
||||
"error_boundary_description": "這顯然不應該發生。對此非常抱歉。<br/>如果你有時間,請在<githubLink>GitHub</githubLink>回報,或透過<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>聯絡我們。",
|
||||
"error_boundary_gathering_info": "收集更多資訊……",
|
||||
"error_boundary_stack_trace": "堆疊追踪",
|
||||
"error_boundary_stack_trace": "堆疊追蹤",
|
||||
"error_boundary_title": "天啊,ntfy 崩潰了",
|
||||
"error_boundary_unsupported_indexeddb_description": "Ntfy Web應用程式需要IndexedDB才能運行,且你的瀏覽器在隱私瀏覽模式下不支援IndexedDB。<br/><br/>儘管這很不幸,但在隱私瀏覽模式下使用ntfy Web應用程式也沒有多大意義,因為所有東西都存儲在瀏覽器存儲中。你可以在<githubLink>本GitHub問題</githubLink>中閱讀有關它的更多資訊,或者在<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>上與我們交談。",
|
||||
"error_boundary_unsupported_indexeddb_description": "ntfy Web 應用程式需要 IndexedDB 才能運作,而你的瀏覽器在隱私瀏覽模式下不支援 IndexedDB。<br/><br/>很遺憾,不過在隱私瀏覽模式下使用 ntfy Web 應用程式其實也不太合理,因為所有資料都儲存在瀏覽器儲存空間中。你可以在<githubLink>這個 GitHub issue</githubLink>中閱讀更多資訊,或透過<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>與我們討論。",
|
||||
"error_boundary_unsupported_indexeddb_title": "不支援隱私瀏覽",
|
||||
"login_disabled": "登錄已禁用",
|
||||
"login_form_button_submit": "登錄",
|
||||
"login_disabled": "登入已停用",
|
||||
"login_form_button_submit": "登入",
|
||||
"login_link_signup": "註冊",
|
||||
"login_title": "請登錄你的 ntfy 帳戶",
|
||||
"message_bar_error_publishing": "發佈通知時出錯",
|
||||
"login_title": "請登入你的 ntfy 帳戶",
|
||||
"message_bar_error_publishing": "發布通知時發生錯誤",
|
||||
"message_bar_publish": "發布訊息",
|
||||
"message_bar_show_dialog": "顯示發布對話框",
|
||||
"message_bar_show_dialog": "顯示發布對話方塊",
|
||||
"message_bar_type_message": "在此處輸入訊息",
|
||||
"nav_button_account": "帳戶",
|
||||
"nav_button_all_notifications": "全部通知",
|
||||
"nav_button_connecting": "正在連接",
|
||||
"nav_button_documentation": "文檔",
|
||||
"nav_button_connecting": "正在連線",
|
||||
"nav_button_documentation": "說明",
|
||||
"nav_button_muted": "已暫停通知",
|
||||
"nav_button_publish_message": "發布通知",
|
||||
"nav_button_settings": "設定",
|
||||
@@ -191,44 +191,44 @@
|
||||
"nav_upgrade_banner_description": "保留主題,更多訊息和郵件,以及更大的附件",
|
||||
"nav_upgrade_banner_label": "升級到 ntfy Pro",
|
||||
"notifications_actions_failed_notification": "通知失敗",
|
||||
"notifications_actions_http_request_title": "發送 HTTP {{method}} 到 {{url}}",
|
||||
"notifications_actions_not_supported": "網頁應用程序不支援此操作",
|
||||
"notifications_actions_http_request_title": "傳送 HTTP {{method}} 到 {{url}}",
|
||||
"notifications_actions_not_supported": "網頁應用程式不支援此操作",
|
||||
"notifications_actions_open_url_title": "轉到 {{url}}",
|
||||
"notifications_attachment_copy_url_button": "複製連結地址",
|
||||
"notifications_attachment_copy_url_title": "將附件中連結地址複製到剪貼板",
|
||||
"notifications_attachment_file_app": "安卓應用程式",
|
||||
"notifications_attachment_file_audio": "聲音文件",
|
||||
"notifications_attachment_file_document": "其他文件",
|
||||
"notifications_attachment_file_image": "圖片文件",
|
||||
"notifications_attachment_file_video": "影片文件",
|
||||
"notifications_attachment_copy_url_button": "複製 URL",
|
||||
"notifications_attachment_copy_url_title": "將附件 URL 複製到剪貼簿",
|
||||
"notifications_attachment_file_app": "Android 應用程式檔案",
|
||||
"notifications_attachment_file_audio": "音訊檔案",
|
||||
"notifications_attachment_file_document": "其他檔案",
|
||||
"notifications_attachment_file_image": "圖片檔案",
|
||||
"notifications_attachment_file_video": "影片檔案",
|
||||
"notifications_attachment_image": "附件圖片",
|
||||
"notifications_attachment_link_expired": "下載連結已過期",
|
||||
"notifications_attachment_link_expires": "連結在 {{date}} 過期",
|
||||
"notifications_attachment_open_button": "打開附件",
|
||||
"notifications_attachment_open_button": "開啟附件",
|
||||
"notifications_attachment_open_title": "轉到 {{url}}",
|
||||
"notifications_click_copy_url_button": "複製鏈結",
|
||||
"notifications_click_copy_url_title": "複製鏈結地址到剪貼板",
|
||||
"notifications_click_open_button": "打開鏈結",
|
||||
"notifications_copied_to_clipboard": "複製到剪貼板",
|
||||
"notifications_click_copy_url_button": "複製連結",
|
||||
"notifications_click_copy_url_title": "將連結 URL 複製到剪貼簿",
|
||||
"notifications_click_open_button": "開啟連結",
|
||||
"notifications_copied_to_clipboard": "複製到剪貼簿",
|
||||
"notifications_delete": "刪除",
|
||||
"notifications_example": "示例",
|
||||
"notifications_example": "範例",
|
||||
"notifications_list_item": "通知",
|
||||
"notifications_list": "通知列表",
|
||||
"notifications_loading": "正在加載通知……",
|
||||
"notifications_list": "通知清單",
|
||||
"notifications_loading": "正在載入通知……",
|
||||
"notifications_mark_read": "標記為已讀",
|
||||
"notifications_more_details": "有關更多資訊,請查看<websiteLink>網站</websiteLink>或<docsLink>文檔</docsLink>。",
|
||||
"notifications_more_details": "如需更多資訊,請參閱<websiteLink>網站</websiteLink>或<docsLink>說明</docsLink>。",
|
||||
"notifications_new_indicator": "新通知",
|
||||
"notifications_none_for_any_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題鏈結即可。以下是使用你的主題的示例。",
|
||||
"notifications_none_for_any_description": "若要向此主題傳送通知,只要將 PUT 或 POST 請求傳送到主題 URL 即可。以下是使用你的主題的範例。",
|
||||
"notifications_none_for_any_title": "你尚未收到任何通知。",
|
||||
"notifications_none_for_topic_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題連結即可。",
|
||||
"notifications_none_for_topic_description": "若要向此主題傳送通知,只要將 PUT 或 POST 請求傳送到主題 URL 即可。",
|
||||
"notifications_none_for_topic_title": "你尚未收到有關此主題的任何通知。",
|
||||
"notifications_no_subscriptions_description": "點擊 \"{{linktext}}\" 連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 發送訊息,你將在這裡收到通知。",
|
||||
"notifications_no_subscriptions_title": "看起來你還未有任何訂閱。",
|
||||
"notifications_priority_x": "優先級 {{priority}}",
|
||||
"notifications_no_subscriptions_description": "點選「{{linktext}}」連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 傳送訊息,並在這裡收到通知。",
|
||||
"notifications_no_subscriptions_title": "看起來你還沒有任何訂閱。",
|
||||
"notifications_priority_x": "優先順序 {{priority}}",
|
||||
"notifications_tags": "標記",
|
||||
"prefs_appearance_language_title": "語言",
|
||||
"prefs_appearance_theme_dark": "黑暗模式",
|
||||
"prefs_appearance_theme_light": "光亮模式",
|
||||
"prefs_appearance_theme_dark": "深色模式",
|
||||
"prefs_appearance_theme_light": "淺色模式",
|
||||
"prefs_appearance_theme_system": "系統 (預設)",
|
||||
"prefs_appearance_theme_title": "主題",
|
||||
"prefs_appearance_title": "外觀",
|
||||
@@ -238,171 +238,171 @@
|
||||
"prefs_notifications_delete_after_one_day": "一天後",
|
||||
"prefs_notifications_delete_after_one_month_description": "一個月後自動刪除通知",
|
||||
"prefs_notifications_delete_after_one_month": "一個月後",
|
||||
"prefs_notifications_delete_after_one_week_description": "一周後自動刪除通知",
|
||||
"prefs_notifications_delete_after_one_week": "一周後",
|
||||
"prefs_notifications_delete_after_one_week_description": "一週後自動刪除通知",
|
||||
"prefs_notifications_delete_after_one_week": "一週後",
|
||||
"prefs_notifications_delete_after_three_hours_description": "三小時後自動刪除通知",
|
||||
"prefs_notifications_delete_after_three_hours": "三小時後",
|
||||
"prefs_notifications_delete_after_title": "刪除通知",
|
||||
"prefs_notifications_min_priority_any": "任意優先級",
|
||||
"prefs_notifications_min_priority_default_and_higher": "默認優先級或更高",
|
||||
"prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先級如何",
|
||||
"prefs_notifications_min_priority_description_max": "僅顯示最高優先級的通知",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先級為{{number}}({{name}})或以上的通知",
|
||||
"prefs_notifications_min_priority_high_and_higher": "高優先級或更高",
|
||||
"prefs_notifications_min_priority_low_and_higher": "低優先級或更高",
|
||||
"prefs_notifications_min_priority_max_only": "僅最高優先級",
|
||||
"prefs_notifications_min_priority_title": "最低優先級",
|
||||
"prefs_notifications_min_priority_any": "任何優先順序",
|
||||
"prefs_notifications_min_priority_default_and_higher": "預設優先順序或更高",
|
||||
"prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先順序如何",
|
||||
"prefs_notifications_min_priority_description_max": "僅顯示最高優先順序的通知",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先順序為 {{number}}({{name}})或以上的通知",
|
||||
"prefs_notifications_min_priority_high_and_higher": "高優先順序或更高",
|
||||
"prefs_notifications_min_priority_low_and_higher": "低優先順序或更高",
|
||||
"prefs_notifications_min_priority_max_only": "僅最高優先順序",
|
||||
"prefs_notifications_min_priority_title": "最低優先順序",
|
||||
"prefs_notifications_sound_description_none": "收到通知時不播放任何聲音",
|
||||
"prefs_notifications_sound_description_some": "收到通知時播放 {{sound}} 聲音",
|
||||
"prefs_notifications_sound_no_sound": "靜音",
|
||||
"prefs_notifications_sound_play": "播放選中聲音",
|
||||
"prefs_notifications_sound_play": "播放選取的聲音",
|
||||
"prefs_notifications_sound_title": "通知提示音",
|
||||
"prefs_notifications_title": "通知",
|
||||
"prefs_notifications_web_push_disabled_description": "當網頁程式在運行時將會收到通知 (透過 WebSocket)",
|
||||
"prefs_notifications_web_push_disabled": "己暫用",
|
||||
"prefs_notifications_web_push_enabled_description": "即使網頁程式未有運街亦會收到通知 (via Web Push)",
|
||||
"prefs_notifications_web_push_enabled": "己為 {{server}} 啟用",
|
||||
"prefs_notifications_web_push_disabled_description": "網頁應用程式執行時會收到通知(透過 WebSocket)",
|
||||
"prefs_notifications_web_push_disabled": "已停用",
|
||||
"prefs_notifications_web_push_enabled_description": "即使網頁應用程式未執行,也會收到通知(透過 Web Push)",
|
||||
"prefs_notifications_web_push_enabled": "已為 {{server}} 啟用",
|
||||
"prefs_notifications_web_push_title": "背景通知",
|
||||
"prefs_reservations_add_button": "新增保留主題",
|
||||
"prefs_reservations_delete_button": "重置主題訪問",
|
||||
"prefs_reservations_description": "你可以在此處保留主題名稱供個人使用。保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。",
|
||||
"prefs_reservations_dialog_access_label": "訪問",
|
||||
"prefs_reservations_dialog_description": "保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。",
|
||||
"prefs_reservations_delete_button": "重設主題存取權限",
|
||||
"prefs_reservations_description": "你可以在此保留主題名稱供個人使用。保留主題代表你擁有該主題的所有權,並可為其他使用者定義該主題的存取權限。",
|
||||
"prefs_reservations_dialog_access_label": "存取權限",
|
||||
"prefs_reservations_dialog_description": "保留主題代表你擁有該主題的所有權,並可為其他使用者定義該主題的存取權限。",
|
||||
"prefs_reservations_dialog_title_add": "保留主題",
|
||||
"prefs_reservations_dialog_title_delete": "刪除主題保留",
|
||||
"prefs_reservations_dialog_title_edit": "編輯保留主題",
|
||||
"prefs_reservations_dialog_topic_label": "主題",
|
||||
"prefs_reservations_edit_button": "編輯主題訪問",
|
||||
"prefs_reservations_edit_button": "編輯主題存取權限",
|
||||
"prefs_reservations_limit_reached": "你已達到保留主題限制。",
|
||||
"prefs_reservations_table_access_header": "訪問",
|
||||
"prefs_reservations_table_click_to_subscribe": "點擊以訂閱",
|
||||
"prefs_reservations_table_everyone_deny_all": "只有我可以發佈和訂閱",
|
||||
"prefs_reservations_table_everyone_read_only": "我可以發佈和訂閱,每個人都可以訂閱",
|
||||
"prefs_reservations_table_everyone_read_write": "每個人都可以發佈和訂閱",
|
||||
"prefs_reservations_table_everyone_write_only": "我可以發佈和訂閱,每個人都可以發佈",
|
||||
"prefs_reservations_table_access_header": "存取權限",
|
||||
"prefs_reservations_table_click_to_subscribe": "點選以訂閱",
|
||||
"prefs_reservations_table_everyone_deny_all": "只有我可以發布和訂閱",
|
||||
"prefs_reservations_table_everyone_read_only": "我可以發布和訂閱,每個人都可以訂閱",
|
||||
"prefs_reservations_table_everyone_read_write": "每個人都可以發布和訂閱",
|
||||
"prefs_reservations_table_everyone_write_only": "我可以發布和訂閱,每個人都可以發布",
|
||||
"prefs_reservations_table_not_subscribed": "未訂閱",
|
||||
"prefs_reservations_table_topic_header": "主題",
|
||||
"prefs_reservations_table": "保留主題表格",
|
||||
"prefs_reservations_title": "保留主題",
|
||||
"prefs_users_add_button": "新增使用者",
|
||||
"prefs_users_delete_button": "刪除用戶",
|
||||
"prefs_users_description_no_sync": "用戶和密碼不會同步到你的賬戶。",
|
||||
"prefs_users_description": "在此處新增/刪除受保護主題的使用者。請注意,使用者名和密碼將存儲在瀏覽器的本地存儲中。",
|
||||
"prefs_users_dialog_base_url_label": "服務連結地址,例如 https://ntfy.sh",
|
||||
"prefs_users_delete_button": "刪除使用者",
|
||||
"prefs_users_description_no_sync": "使用者和密碼不會同步到你的帳戶。",
|
||||
"prefs_users_description": "在此新增或刪除受保護主題的使用者。請注意,使用者名稱和密碼會儲存在瀏覽器的本機儲存空間中。",
|
||||
"prefs_users_dialog_base_url_label": "服務 URL,例如 https://ntfy.sh",
|
||||
"prefs_users_dialog_password_label": "密碼",
|
||||
"prefs_users_dialog_title_add": "新增使用者",
|
||||
"prefs_users_dialog_title_edit": "編輯使用者",
|
||||
"prefs_users_dialog_username_label": "使用者名,例如 phil",
|
||||
"prefs_users_edit_button": "編輯用戶",
|
||||
"prefs_users_table_base_url_header": "服務連結地址",
|
||||
"prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登錄用戶",
|
||||
"prefs_users_table_user_header": "用戶",
|
||||
"prefs_users_table": "用戶表",
|
||||
"prefs_users_dialog_username_label": "使用者名稱,例如 phil",
|
||||
"prefs_users_edit_button": "編輯使用者",
|
||||
"prefs_users_table_base_url_header": "服務 URL",
|
||||
"prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登入使用者",
|
||||
"prefs_users_table_user_header": "使用者",
|
||||
"prefs_users_table": "使用者表格",
|
||||
"prefs_users_title": "管理使用者",
|
||||
"priority_default": "預設",
|
||||
"priority_high": "高",
|
||||
"priority_low": "低",
|
||||
"priority_max": "最高",
|
||||
"priority_min": "最低",
|
||||
"publish_dialog_attached_file_filename_placeholder": "附件文件名",
|
||||
"publish_dialog_attached_file_remove": "刪除附件文件",
|
||||
"publish_dialog_attached_file_title": "附件文件:",
|
||||
"publish_dialog_attach_label": "附件連結地址",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 文件限制和配額,剩餘 {{remainingBytes}}",
|
||||
"publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 文件限制",
|
||||
"publish_dialog_attached_file_filename_placeholder": "附件檔名",
|
||||
"publish_dialog_attached_file_remove": "刪除附件檔案",
|
||||
"publish_dialog_attached_file_title": "附件檔案:",
|
||||
"publish_dialog_attach_label": "附件 URL",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 的檔案限制和配額,剩餘 {{remainingBytes}}",
|
||||
"publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 的檔案限制",
|
||||
"publish_dialog_attachment_limits_quota_reached": "超過配額,剩餘 {{remainingBytes}}",
|
||||
"publish_dialog_attach_placeholder": "使用鏈結地址附加文件,例如 https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "移除附件鏈結地址",
|
||||
"publish_dialog_base_url_label": "服務鏈結地址",
|
||||
"publish_dialog_base_url_placeholder": "服務鏈結地址,例如 https://example.com",
|
||||
"publish_dialog_button_cancel_sending": "取消發送",
|
||||
"publish_dialog_attach_placeholder": "透過 URL 附加檔案,例如 https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "移除附件 URL",
|
||||
"publish_dialog_base_url_label": "服務 URL",
|
||||
"publish_dialog_base_url_placeholder": "服務 URL,例如 https://example.com",
|
||||
"publish_dialog_button_cancel_sending": "取消傳送",
|
||||
"publish_dialog_button_cancel": "取消",
|
||||
"publish_dialog_button_send": "發送",
|
||||
"publish_dialog_button_send": "傳送",
|
||||
"publish_dialog_call_item": "撥打電話 {{number}}",
|
||||
"publish_dialog_call_label": "撥號",
|
||||
"publish_dialog_call_reset": "清空撥號",
|
||||
"publish_dialog_checkbox_markdown": "格式化為 Markdown",
|
||||
"publish_dialog_checkbox_publish_another": "發布另一個",
|
||||
"publish_dialog_chip_attach_file_label": "本地文件附件",
|
||||
"publish_dialog_chip_attach_url_label": "鏈結附件地址",
|
||||
"publish_dialog_chip_attach_file_label": "本機檔案附件",
|
||||
"publish_dialog_chip_attach_url_label": "透過 URL 附加檔案",
|
||||
"publish_dialog_chip_call_label": "撥號",
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "未驗證的電話號碼",
|
||||
"publish_dialog_chip_click_label": "點擊鏈結地址",
|
||||
"publish_dialog_chip_click_label": "點選 URL",
|
||||
"publish_dialog_chip_delay_label": "延期投遞",
|
||||
"publish_dialog_chip_email_label": "轉發郵件",
|
||||
"publish_dialog_chip_email_label": "轉寄電子郵件",
|
||||
"publish_dialog_chip_topic_label": "變更主題",
|
||||
"publish_dialog_click_label": "點擊鏈結地址",
|
||||
"publish_dialog_click_placeholder": "點擊通知時打開鏈結地址",
|
||||
"publish_dialog_click_reset": "移除點擊連結地址",
|
||||
"publish_dialog_click_label": "點選 URL",
|
||||
"publish_dialog_click_placeholder": "點選通知時開啟的 URL",
|
||||
"publish_dialog_click_reset": "移除點選 URL",
|
||||
"publish_dialog_delay_label": "延期",
|
||||
"publish_dialog_delay_placeholder": "延期投遞,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(僅限英語)",
|
||||
"publish_dialog_delay_reset": "刪除延期投遞",
|
||||
"publish_dialog_details_examples_description": "有關所有發送功能的範例和詳細說明,請參閱<docsLink>文檔</docsLink>。",
|
||||
"publish_dialog_drop_file_here": "將文件拖拽至此",
|
||||
"publish_dialog_details_examples_description": "如需所有傳送功能的範例和詳細說明,請參閱<docsLink>說明</docsLink>。",
|
||||
"publish_dialog_drop_file_here": "將檔案拖曳到這裡",
|
||||
"publish_dialog_email_label": "電子郵件",
|
||||
"publish_dialog_email_placeholder": "將通知轉發到的地址,例如 phil@example.com",
|
||||
"publish_dialog_email_reset": "移除電子郵件轉發",
|
||||
"publish_dialog_email_placeholder": "要轉寄通知的電子郵件地址,例如 phil@example.com",
|
||||
"publish_dialog_email_reset": "移除電子郵件轉寄",
|
||||
"publish_dialog_emoji_picker_show": "選擇表情符號",
|
||||
"publish_dialog_filename_label": "文件名",
|
||||
"publish_dialog_filename_placeholder": "附件文件名",
|
||||
"publish_dialog_filename_label": "檔名",
|
||||
"publish_dialog_filename_placeholder": "附件檔名",
|
||||
"publish_dialog_message_label": "訊息",
|
||||
"publish_dialog_message_placeholder": "在此輸入訊息",
|
||||
"publish_dialog_message_published": "已發布通知",
|
||||
"publish_dialog_other_features": "其它功能:",
|
||||
"publish_dialog_priority_default": "默認優先級",
|
||||
"publish_dialog_priority_high": "高優先級",
|
||||
"publish_dialog_priority_label": "優先級",
|
||||
"publish_dialog_priority_low": "低優先級",
|
||||
"publish_dialog_priority_max": "最高優先級",
|
||||
"publish_dialog_priority_min": "最低優先級",
|
||||
"publish_dialog_other_features": "其他功能:",
|
||||
"publish_dialog_priority_default": "預設優先順序",
|
||||
"publish_dialog_priority_high": "高優先順序",
|
||||
"publish_dialog_priority_label": "優先順序",
|
||||
"publish_dialog_priority_low": "低優先順序",
|
||||
"publish_dialog_priority_max": "最高優先順序",
|
||||
"publish_dialog_priority_min": "最低優先順序",
|
||||
"publish_dialog_progress_uploading_detail": "正在上傳 {{loaded}}/{{total}} ({{percent}}%) ……",
|
||||
"publish_dialog_progress_uploading": "正在上傳……",
|
||||
"publish_dialog_tags_label": "標記",
|
||||
"publish_dialog_tags_placeholder": "英文逗號分隔標記列表,例如 warning, srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "以英文逗號分隔的標記清單,例如 warning, srv1-backup",
|
||||
"publish_dialog_title_label": "主題",
|
||||
"publish_dialog_title_no_topic": "發布通知",
|
||||
"publish_dialog_title_placeholder": "主題標題,例如:磁碟空間警告",
|
||||
"publish_dialog_title_topic": "發布到 {{topic}}",
|
||||
"publish_dialog_topic_label": "主題名稱",
|
||||
"publish_dialog_topic_placeholder": "主題名稱,例如 phil_alerts",
|
||||
"publish_dialog_topic_reset": "重置主題",
|
||||
"reservation_delete_dialog_action_delete_description": "緩存的郵件和附件將被永久刪除。此操作無法撤銷。",
|
||||
"reservation_delete_dialog_action_delete_title": "刪除緩存的郵件和附件",
|
||||
"reservation_delete_dialog_action_keep_description": "緩存在伺服器上的訊息和附件將對知道主題名稱的人公開可見。",
|
||||
"reservation_delete_dialog_action_keep_title": "保留緩存的郵件和附件",
|
||||
"reservation_delete_dialog_description": "刪除保留會放棄對該主題的所有權,並允許其他人保留它。你可以保留或刪除現有郵件和附件。",
|
||||
"publish_dialog_topic_reset": "重設主題",
|
||||
"reservation_delete_dialog_action_delete_description": "系統會永久刪除快取的訊息和附件。此操作無法復原。",
|
||||
"reservation_delete_dialog_action_delete_title": "刪除快取的訊息和附件",
|
||||
"reservation_delete_dialog_action_keep_description": "只要知道主題名稱,任何人都能看到伺服器快取的訊息和附件。",
|
||||
"reservation_delete_dialog_action_keep_title": "保留快取的訊息和附件",
|
||||
"reservation_delete_dialog_description": "刪除保留會放棄該主題的所有權,並允許其他人保留。你可以保留或刪除現有訊息和附件。",
|
||||
"reservation_delete_dialog_submit_button": "刪除保留",
|
||||
"reserve_dialog_checkbox_label": "保留主題並配置訪問",
|
||||
"signup_already_have_account": "已有帳戶?登錄!",
|
||||
"signup_disabled": "註冊已禁用",
|
||||
"signup_error_creation_limit_reached": "已達到帳戶創建限制",
|
||||
"signup_error_username_taken": "用戶名 {{username}} 已被取用",
|
||||
"reserve_dialog_checkbox_label": "保留主題並設定存取權限",
|
||||
"signup_already_have_account": "已有帳戶?請登入!",
|
||||
"signup_disabled": "註冊已停用",
|
||||
"signup_error_creation_limit_reached": "已達到帳戶建立限制",
|
||||
"signup_error_username_taken": "使用者名稱 {{username}} 已被取用",
|
||||
"signup_form_button_submit": "註冊",
|
||||
"signup_form_confirm_password": "確認密碼",
|
||||
"signup_form_password": "密碼",
|
||||
"signup_form_toggle_password_visibility": "切換密碼可見性",
|
||||
"signup_form_username": "用戶名",
|
||||
"signup_title": "創建一個 ntfy 帳戶",
|
||||
"signup_form_username": "使用者名稱",
|
||||
"signup_title": "建立 ntfy 帳戶",
|
||||
"subscribe_dialog_error_topic_already_reserved": "主題已保留",
|
||||
"subscribe_dialog_error_user_anonymous": "匿名",
|
||||
"subscribe_dialog_error_user_not_authorized": "未授權 {{username}} 使用者",
|
||||
"subscribe_dialog_login_button_login": "登入",
|
||||
"subscribe_dialog_login_description": "本主題受密碼保護,請輸入用戶名和密碼以訂閱。",
|
||||
"subscribe_dialog_login_description": "此主題受到密碼保護,請輸入使用者名稱和密碼以訂閱。",
|
||||
"subscribe_dialog_login_password_label": "密碼",
|
||||
"subscribe_dialog_login_title": "請登錄",
|
||||
"subscribe_dialog_login_username_label": "用戶名,例如 phil",
|
||||
"subscribe_dialog_subscribe_base_url_label": "服務地址地址",
|
||||
"subscribe_dialog_login_title": "請登入",
|
||||
"subscribe_dialog_login_username_label": "使用者名稱,例如 phil",
|
||||
"subscribe_dialog_subscribe_base_url_label": "服務 URL",
|
||||
"subscribe_dialog_subscribe_button_cancel": "取消",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "生成名稱",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "產生名稱",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "訂閱",
|
||||
"subscribe_dialog_subscribe_description": "主題可能不受密碼保護,因此請選擇一個不容易被猜中的名字。訂閱後,你可以使用 PUT/POST 通知。",
|
||||
"subscribe_dialog_subscribe_title": "訂閱主題",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "主題名,例如 phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "當網頁程式未開啟, 將不會收到來自其他伺服器的通知",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "主題名稱,例如 phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "網頁應用程式未開啟時,不會收到來自其他伺服器的通知",
|
||||
"subscribe_dialog_subscribe_use_another_label": "使用其他伺服器",
|
||||
"web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知",
|
||||
"web_push_subscription_expiring_body": "開啟 ntfy 以繼續接收通知",
|
||||
"web_push_subscription_expiring_title": "通知會被暫停",
|
||||
"web_push_unknown_notification_body": "你可能需要開啟網頁來更新ntfy",
|
||||
"web_push_unknown_notification_body": "你可能需要開啟網頁來更新 ntfy",
|
||||
"web_push_unknown_notification_title": "接收到不明通知",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "已佈建的使用者無法編輯或刪除",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "無法編輯或刪除已佈建的權杖"
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
+16
-6
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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?:\/\/.+/);
|
||||
|
||||
|
||||
+205
-129
@@ -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 = () => {
|
||||
>
|
||||
<div>
|
||||
{accountType}
|
||||
{qualifierChip && <Chip size="small" label={qualifierChip} sx={{ ml: 1 }} />}
|
||||
{account.provisioned && <Chip size="small" label={t("account_basics_tier_provisioned")} sx={{ ml: 1 }} />}
|
||||
{account.billing?.paid_until && !account.billing?.cancel_at && (
|
||||
<Tooltip
|
||||
title={t("account_basics_tier_paid_until", {
|
||||
@@ -359,9 +369,24 @@ const Emails = () => {
|
||||
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 (
|
||||
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||
<Pref labelId={labelId} alignTop title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||
<div aria-labelledby={labelId}>
|
||||
{account?.emails?.map((email) => (
|
||||
<Chip
|
||||
key={email}
|
||||
label={
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<span>{email}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={() => handleCopy(email)}
|
||||
onDelete={() => handleDelete(email)}
|
||||
/>
|
||||
))}
|
||||
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
||||
<IconButton onClick={handleDialogOpen}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.75 }}>
|
||||
{verifiedEmails.map((email) => (
|
||||
<Chip
|
||||
key={email.address}
|
||||
icon={email.primary ? <StarIcon /> : undefined}
|
||||
label={
|
||||
<Tooltip
|
||||
title={email.primary ? t("account_basics_emails_chip_actions_primary") : t("account_basics_emails_chip_actions_verified")}
|
||||
>
|
||||
<span>{email.address}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={(ev) => openMenu(ev, email)}
|
||||
onDelete={() => handleDelete(email.address)}
|
||||
sx={email.primary ? { "& .MuiChip-icon": { color: "#fbc02d" } } : undefined}
|
||||
/>
|
||||
))}
|
||||
{pendingEmails.map((email) => (
|
||||
<Chip
|
||||
key={email.address}
|
||||
label={
|
||||
<Tooltip title={t("account_basics_emails_chip_actions_unverified")}>
|
||||
<span>
|
||||
{email.address} <em>({t("account_basics_emails_unverified")})</em>
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={(ev) => openMenu(ev, email)}
|
||||
onDelete={() => handleDelete(email.address)}
|
||||
sx={{ opacity: 0.7 }}
|
||||
/>
|
||||
))}
|
||||
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
||||
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_emails_dialog_title")}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{showNoEmailWarning && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
{t("account_basics_emails_no_recovery_warning")}
|
||||
</Alert>
|
||||
)}
|
||||
{showNoPrimaryWarning && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
{t("account_basics_emails_no_primary_warning")}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
|
||||
<MenuItem onClick={() => runMenuAction(handleCopy)}>
|
||||
<ListItemIcon>
|
||||
<ContentCopy fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("common_copy_to_clipboard")}</ListItemText>
|
||||
</MenuItem>
|
||||
{menuEmail && !menuEmail.pending && !menuEmail.primary && (
|
||||
<MenuItem onClick={() => runMenuAction(handleSetPrimary)}>
|
||||
<ListItemIcon>
|
||||
<StarBorderIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("account_basics_emails_set_primary")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuEmail && menuEmail.pending && (
|
||||
<MenuItem onClick={() => runMenuAction(handleResend)}>
|
||||
<ListItemIcon>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("account_basics_emails_resend")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => runMenuAction(handleDelete)}>
|
||||
<ListItemIcon>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("account_basics_emails_delete")}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||
<Portal>
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message={t("account_basics_emails_copied_to_clipboard")}
|
||||
/>
|
||||
<Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
|
||||
</Portal>
|
||||
</Pref>
|
||||
);
|
||||
@@ -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 (
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
|
||||
{!verificationCodeSent && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("account_basics_emails_dialog_email_label")}
|
||||
aria-label={t("account_basics_emails_dialog_email_label")}
|
||||
placeholder={t("account_basics_emails_dialog_email_placeholder")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ev) => setEmail(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
)}
|
||||
{verificationCodeSent && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("account_basics_emails_dialog_code_label")}
|
||||
aria-label={t("account_basics_emails_dialog_code_label")}
|
||||
placeholder={t("account_basics_emails_dialog_code_placeholder")}
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(ev) => setCode(ev.target.value)}
|
||||
fullWidth
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
variant="standard"
|
||||
/>
|
||||
{sent ? (
|
||||
<DialogContentText>{t("account_basics_emails_dialog_check_inbox")}</DialogContentText>
|
||||
) : (
|
||||
<>
|
||||
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label={t("account_basics_emails_dialog_email_label")}
|
||||
aria-label={t("account_basics_emails_dialog_email_label")}
|
||||
placeholder={t("account_basics_emails_dialog_email_placeholder")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ev) => setEmail(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
|
||||
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
|
||||
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
|
||||
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
|
||||
</Button>
|
||||
{sent ? (
|
||||
<Button onClick={props.onClose}>{t("common_close")}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
|
||||
{t("account_basics_emails_dialog_verify_button")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -604,22 +678,24 @@ const PhoneNumbers = () => {
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
|
||||
<div aria-labelledby={labelId}>
|
||||
{account?.phone_numbers?.map((phoneNumber) => (
|
||||
<Chip
|
||||
label={
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<span>{phoneNumber}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={() => handleCopy(phoneNumber)}
|
||||
onDelete={() => handleDelete(phoneNumber)}
|
||||
/>
|
||||
))}
|
||||
{!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
|
||||
<IconButton onClick={handleDialogOpen}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.75 }}>
|
||||
{account?.phone_numbers?.map((phoneNumber) => (
|
||||
<Chip
|
||||
label={
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<span>{phoneNumber}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={() => handleCopy(phoneNumber)}
|
||||
onDelete={() => handleDelete(phoneNumber)}
|
||||
/>
|
||||
))}
|
||||
{!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
|
||||
<IconButton onClick={handleDialogOpen}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</div>
|
||||
<AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||
<Portal>
|
||||
|
||||
@@ -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 = () => {
|
||||
<Routes>
|
||||
<Route path={routes.login} element={<Login />} />
|
||||
<Route path={routes.signup} element={<Signup />} />
|
||||
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
|
||||
<Route path={routes.passwordReset} element={<PasswordReset />} />
|
||||
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||
<Route path={routes.account} element={<Account />} />
|
||||
|
||||
@@ -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 (
|
||||
<AvatarBox>
|
||||
{status === STATUS_VERIFYING && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_progress_title")}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{status === STATUS_SUCCESS && (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 28 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_success_description")}</Typography>
|
||||
<Button onClick={() => navigate(routes.account)} variant="contained" sx={{ mt: 2 }}>
|
||||
{t("email_verify_button_account")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === STATUS_ERROR && (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 28 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_error_description")}</Typography>
|
||||
<Button onClick={() => navigate(routes.account)} variant="contained" sx={{ mt: 2 }}>
|
||||
{t("email_verify_button_account")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</AvatarBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerify;
|
||||
@@ -100,7 +100,13 @@ const Login = () => {
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ width: "100%" }}>
|
||||
{/* This is where the password reset link would go */}
|
||||
{config.enable_reset_password && (
|
||||
<div style={{ float: "left" }}>
|
||||
<NavLink to={routes.passwordResetRequest} variant="body1">
|
||||
{t("login_link_forgot_password")}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
{config.enable_signup && (
|
||||
<div style={{ float: "right" }}>
|
||||
<NavLink to={routes.signup} variant="body1">
|
||||
|
||||
@@ -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 (
|
||||
<AvatarBox>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 28 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("reset_password_success_title")}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("reset_password_success_description")}</Typography>
|
||||
<Button onClick={() => navigate(routes.login)} variant="contained" sx={{ mt: 2 }}>
|
||||
{t("login_form_button_submit")}
|
||||
</Button>
|
||||
</AvatarBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarBox>
|
||||
<Typography sx={{ typography: "h6" }}>{t("reset_password_title")}</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label={t("reset_password_form_password")}
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value.trim())}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
required
|
||||
fullWidth
|
||||
name="confirm"
|
||||
label={t("reset_password_form_confirm")}
|
||||
type="password"
|
||||
id="confirm"
|
||||
value={confirm}
|
||||
onChange={(ev) => setConfirm(ev.target.value.trim())}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={sending || password === "" || confirm === "" || password !== confirm}
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
>
|
||||
{t("reset_password_form_button_submit")}
|
||||
</Button>
|
||||
{error && (
|
||||
<Box sx={{ mb: 1, display: "flex", flexGrow: 1, justifyContent: "center" }}>
|
||||
<WarningAmberIcon color="error" sx={{ mr: 1 }} />
|
||||
<Typography sx={{ color: "error.main" }}>{error}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AvatarBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordReset;
|
||||
@@ -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 (
|
||||
<AvatarBox>
|
||||
<Typography sx={{ typography: "h6" }}>{t("reset_password_disabled")}</Typography>
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
<NavLink to={routes.login} variant="body1">
|
||||
{t("reset_password_back_to_login")}
|
||||
</NavLink>
|
||||
</Typography>
|
||||
</AvatarBox>
|
||||
);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<AvatarBox>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 28 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("reset_password_sent_title")}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("reset_password_sent_description")}</Typography>
|
||||
<Typography sx={{ mt: 2, mb: 4 }}>
|
||||
<NavLink to={routes.login} variant="body1">
|
||||
{t("reset_password_back_to_login")}
|
||||
</NavLink>
|
||||
</Typography>
|
||||
</AvatarBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarBox>
|
||||
<Typography sx={{ typography: "h6" }}>{t("reset_password_request_title")}</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
|
||||
<Typography sx={{ mt: 1 }}>{t("reset_password_request_description")}</Typography>
|
||||
<Typography sx={{ mt: 1, mb: 1.5, fontWeight: "bold" }}>{t("reset_password_request_primary_required")}</Typography>
|
||||
<TextField
|
||||
margin="dense"
|
||||
required
|
||||
fullWidth
|
||||
id="identifier"
|
||||
label={t("reset_password_request_identifier_label")}
|
||||
name="identifier"
|
||||
value={identifier}
|
||||
onChange={(ev) => setIdentifier(ev.target.value.trim())}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" fullWidth variant="contained" disabled={sending || identifier === ""} sx={{ mt: 2, mb: 2 }}>
|
||||
{t("reset_password_request_button_submit")}
|
||||
</Button>
|
||||
</Box>
|
||||
{config.enable_login && (
|
||||
<Typography sx={{ mb: 4 }}>
|
||||
<NavLink to={routes.login} variant="body1">
|
||||
{t("reset_password_back_to_login")}
|
||||
</NavLink>
|
||||
</Typography>
|
||||
)}
|
||||
</AvatarBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordResetRequest;
|
||||
@@ -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 && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
fullWidth
|
||||
id="email"
|
||||
label={t("signup_form_email")}
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ev) => setEmail(ev.target.value.trim())}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
margin="dense"
|
||||
required
|
||||
|
||||
@@ -2,11 +2,14 @@ import config from "../app/config";
|
||||
import { shortUrl } from "../app/utils";
|
||||
|
||||
const routes = {
|
||||
app: config.app_root,
|
||||
login: "/login",
|
||||
signup: "/signup",
|
||||
app: config.app_root,
|
||||
account: "/account",
|
||||
settings: "/settings",
|
||||
passwordResetRequest: "/reset-password",
|
||||
passwordReset: "/account/password/reset/:token",
|
||||
emailVerify: "/account/email/verify/:token",
|
||||
subscription: "/:topic",
|
||||
subscriptionExternal: "/:baseUrl/:topic",
|
||||
forSubscription: (subscription) => {
|
||||
|
||||
Reference in New Issue
Block a user